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:
|
||||
|
||||
@@ -422,6 +422,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
||||
assert claim is not None
|
||||
attached_items = [item for item in claim.items if item.invoice_id]
|
||||
assert len(attached_items) == 2
|
||||
pre_review_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
]
|
||||
assert len(pre_review_flags) == 1
|
||||
assert pre_review_flags[0]["created_at"]
|
||||
receipt_links = list(
|
||||
db.scalars(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
|
||||
@@ -438,6 +445,13 @@ def test_attachment_association_job_links_receipts_after_conversation_exit(
|
||||
"receipt_received",
|
||||
"attachment_associated",
|
||||
}
|
||||
expense_case = db.scalar(
|
||||
select(ExpenseCase).where(
|
||||
ExpenseCase.id == receipt_links[0].expense_case_id
|
||||
)
|
||||
)
|
||||
assert expense_case is not None
|
||||
assert expense_case.current_stage == "claiming"
|
||||
|
||||
linked_receipts = receipt_service.list_receipts(
|
||||
current_user=current_user, status_filter="linked"
|
||||
|
||||
@@ -246,7 +246,7 @@ def test_expense_case_timeline_rejects_cross_tenant_lookup(
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "该单据尚未纳入统一费用事件。"
|
||||
assert response.json()["detail"] == "费用单据不存在。"
|
||||
|
||||
|
||||
def test_expense_case_timeline_returns_not_covered_for_claim_without_case(
|
||||
|
||||
@@ -11,13 +11,14 @@ from sqlalchemy.pool import StaticPool
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
@@ -120,6 +121,302 @@ def test_event_write_uses_caller_transaction_and_tenant_scope() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_pre_review_records_one_idempotent_expense_case_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW", employee=employee)
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"label": "票据风险",
|
||||
"message": "票据金额与行程不匹配。",
|
||||
}
|
||||
]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
first = service.pre_review_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id="pre-review-request-1",
|
||||
idempotency_key="pre-review-request-1",
|
||||
)
|
||||
repeated = service.pre_review_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id="pre-review-request-1",
|
||||
idempotency_key="pre-review-request-1",
|
||||
)
|
||||
|
||||
assert first is not None and repeated is not None
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.correlation_id == "pre-review-request-1"
|
||||
assert event.idempotency_key.startswith("pre-review:")
|
||||
assert event.payload_json["review_status"] == "failed"
|
||||
assert event.payload_json["passed"] is False
|
||||
assert event.payload_json["blocking_risk_count"] == 1
|
||||
assert event.payload_json["business_stage"] == "reimbursement"
|
||||
assert "重大风险" in event.payload_json["message"]
|
||||
assert db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id))
|
||||
|
||||
|
||||
def test_pre_review_event_failure_rolls_back_claim_and_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-rollback@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-ROLLBACK-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-rollback-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-ROLLBACK",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-ROLLBACK", employee=employee)
|
||||
original_flags = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"label": "原始风险",
|
||||
"message": "必须保留。",
|
||||
}
|
||||
]
|
||||
claim.risk_flags_json = original_flags
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
def fail_event(*_args, **_kwargs):
|
||||
raise RuntimeError("simulated pre-review event failure")
|
||||
|
||||
monkeypatch.setattr(ExpenseCaseService, "record_claim_event", fail_event)
|
||||
with pytest.raises(RuntimeError, match="simulated pre-review event failure"):
|
||||
ExpenseClaimService(db).pre_review_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
correlation_id="pre-review-rollback",
|
||||
)
|
||||
|
||||
persisted_claim = db.get(ExpenseClaim, claim_id)
|
||||
assert persisted_claim is not None
|
||||
assert persisted_claim.risk_flags_json == original_flags
|
||||
assert db.scalar(select(ExpenseCase)) is None
|
||||
assert db.scalar(select(ExpenseCaseLink)) is None
|
||||
assert db.scalar(select(BusinessEvent)) is None
|
||||
|
||||
|
||||
def test_submit_blocks_fixable_pre_review_before_budget_and_submission_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="pre-review-block-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="PRE-REVIEW-BLOCK-MANAGER",
|
||||
name="李经理",
|
||||
email="pre-review-block-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="PRE-REVIEW-BLOCK-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(claim_no="RE-CASE-PRE-REVIEW-BLOCK", employee=employee)
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "票据与明细不一致",
|
||||
"message": "请更正费用明细或重新上传正确票据。",
|
||||
"item_ids": [claim.items[0].id],
|
||||
}
|
||||
]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "needs_fix"
|
||||
|
||||
def fail_budget(*_args, **_kwargs):
|
||||
raise AssertionError("预审阻断后不应占用预算")
|
||||
|
||||
monkeypatch.setattr(service, "_reserve_budget_for_submission", fail_budget)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
persisted_claim = db.get(ExpenseClaim, claim.id)
|
||||
assert persisted_claim is not None
|
||||
assert persisted_claim.status == "draft"
|
||||
assert persisted_claim.submitted_at is None
|
||||
pre_review_events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(pre_review_events) == 1
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
assert db.scalar(select(BudgetReservation)) is None
|
||||
assert db.scalar(select(BudgetTransaction)) is None
|
||||
|
||||
|
||||
def test_submit_rechecks_dynamic_risk_context_and_rejects_stale_ready_review(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="dynamic-review-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="DYNAMIC-REVIEW-MANAGER",
|
||||
name="李经理",
|
||||
email="dynamic-review-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="DYNAMIC-REVIEW-OWNER",
|
||||
name="张三",
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(
|
||||
claim_no="RE-CASE-DYNAMIC-REVIEW",
|
||||
employee=employee,
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
review_calls = 0
|
||||
|
||||
def dynamic_review(_claim):
|
||||
nonlocal review_calls
|
||||
review_calls += 1
|
||||
risk_flags = []
|
||||
if review_calls > 1:
|
||||
risk_flags = [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "重复发票",
|
||||
"message": "预审后发现同一发票已被其他单据使用。",
|
||||
}
|
||||
]
|
||||
return {
|
||||
"risk_flags": risk_flags,
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, "_run_ai_submission_review", dynamic_review)
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
ready_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert ready_flag["decision"] == "ready"
|
||||
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=ready_flag["review_id"],
|
||||
pre_review_input_fingerprint=ready_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert error_info.value.code == "PRE_REVIEW_NEEDS_FIX"
|
||||
assert error_info.value.review["review_id"] != ready_flag["review_id"]
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
pre_review_events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert len(pre_review_events) == 2
|
||||
|
||||
|
||||
def test_legacy_bootstrap_excludes_migration_owned_tables(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -215,12 +512,24 @@ def test_submit_claim_creates_case_link_and_structured_event() -> None:
|
||||
assert submitted.status == "submitted"
|
||||
link = db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == submitted.id))
|
||||
assert link is not None
|
||||
event = db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_id == submitted.id))
|
||||
assert event is not None
|
||||
assert event.event_type == "claim_submitted"
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.payload_json["previous_status"] == "draft"
|
||||
assert event.payload_json["next_status"] == "submitted"
|
||||
pre_review_event = db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == submitted.id,
|
||||
BusinessEvent.event_type == "claim_pre_review_completed",
|
||||
)
|
||||
)
|
||||
submitted_event = db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == submitted.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
)
|
||||
assert pre_review_event is not None and submitted_event is not None
|
||||
assert submitted_event.delivery_status == "pending"
|
||||
assert submitted_event.correlation_id == pre_review_event.correlation_id
|
||||
assert submitted_event.causation_id == pre_review_event.id
|
||||
assert submitted_event.payload_json["previous_status"] == "draft"
|
||||
assert submitted_event.payload_json["next_status"] == "submitted"
|
||||
|
||||
|
||||
def test_payment_event_failure_rolls_back_payment_archive_and_nested_audit(
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
@@ -311,7 +312,7 @@ def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() ->
|
||||
)
|
||||
|
||||
|
||||
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
||||
def test_high_risk_application_under_90_percent_routes_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||
_seed_budget_allocation(
|
||||
@@ -362,16 +363,169 @@ def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manage
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||
][0]
|
||||
assert route_flag["requires_budget_review"] is False
|
||||
assert route_flag["route"] == "approval_done"
|
||||
assert route_flag["requires_budget_review"] is True
|
||||
assert route_flag["route"] == "budget_manager"
|
||||
assert route_flag["current_risk_count"] == 1
|
||||
assert any("申请信息风险" in reason for reason in route_flag["reasons"])
|
||||
|
||||
|
||||
def test_fixable_high_risk_application_is_blocked_before_direct_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="FIXABLE-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-FIXABLE-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"label": "申请事由不完整",
|
||||
"message": "请先补充客户与项目说明。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "needs_fix"
|
||||
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError):
|
||||
service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
|
||||
assert db.get(ExpenseClaim, claim.id).status == "draft"
|
||||
|
||||
|
||||
def test_review_decision_high_risk_application_flows_from_pre_review_to_p8() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="REVIEW-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-REVIEW-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "review_decision",
|
||||
"label": "特殊项目风险",
|
||||
"message": "该项目需预算管理者确认。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
current_user = CurrentUserContext(
|
||||
username=employee.email,
|
||||
name=employee.name,
|
||||
employee_id=employee.id,
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
reviewed = service.pre_review_claim(claim.id, current_user)
|
||||
assert reviewed is not None
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in reviewed.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["decision"] == "ready_with_review"
|
||||
submitted = service.submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
pre_review_id=pre_review_flag["review_id"],
|
||||
pre_review_input_fingerprint=pre_review_flag["input_fingerprint"],
|
||||
)
|
||||
assert submitted is not None
|
||||
assert submitted.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
|
||||
routed = service.approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
tenant_id="default",
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
assert routed is not None
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
@@ -520,6 +674,65 @@ def test_risky_reimbursement_routes_to_budget_then_finance() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_resolved_high_risk_application_does_not_route_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(
|
||||
db,
|
||||
suffix="RESOLVED-RISK-APP",
|
||||
)
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-RESOLVED-RISK",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "已整改风险",
|
||||
"message": "申请人已完成补充说明。",
|
||||
"business_stage": "expense_application",
|
||||
"resolution_status": "resolved",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
routed = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="风险已整改,同意申请。",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "approved"
|
||||
assert routed.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_budget_manager_blank_opinion_defaults_to_agree_when_budget_under_warning() -> None:
|
||||
with build_session() as db:
|
||||
department, _manager, budget_manager, employee = _seed_people(db, suffix="BUDGET-NORMAL")
|
||||
|
||||
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
196
server/tests/test_expense_claim_pre_review_decision.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.services.expense_claim_pre_review_decision import (
|
||||
build_pre_review_decision,
|
||||
pre_review_identity_matches,
|
||||
)
|
||||
|
||||
|
||||
def build_claim(*, risk_flags: list[dict] | None = None) -> ExpenseClaim:
|
||||
claim = ExpenseClaim(
|
||||
id="claim-pre-review-decision",
|
||||
claim_no="RE-PRE-REVIEW-DECISION",
|
||||
employee_id="employee-pre-review",
|
||||
employee_name="张三",
|
||||
department_id="department-pre-review",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-PRE-REVIEW",
|
||||
expense_type="travel",
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=list(risk_flags or []),
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
id="item-pre-review-decision",
|
||||
claim_id=claim.id,
|
||||
item_date=date(2026, 7, 16),
|
||||
item_type="travel",
|
||||
item_reason="客户现场交通",
|
||||
item_location="上海",
|
||||
item_note="",
|
||||
item_amount=Decimal("88.00"),
|
||||
invoice_id="receipt.png",
|
||||
)
|
||||
]
|
||||
return claim
|
||||
|
||||
|
||||
def test_pre_review_identity_is_stable_when_risk_input_order_changes() -> None:
|
||||
risk_flags = [
|
||||
{"source": "manual_a", "severity": "low", "message": "A"},
|
||||
{"source": "manual_b", "severity": "medium", "message": "B"},
|
||||
]
|
||||
first = build_pre_review_decision(
|
||||
build_claim(risk_flags=risk_flags),
|
||||
risk_flags=risk_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
repeated = build_pre_review_decision(
|
||||
build_claim(risk_flags=list(reversed(risk_flags))),
|
||||
risk_flags=list(reversed(risk_flags)),
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert repeated["review_id"] == first["review_id"]
|
||||
assert repeated["input_fingerprint"] == first["input_fingerprint"]
|
||||
|
||||
|
||||
def test_pre_review_identity_is_stable_when_nested_risk_ids_change_order() -> None:
|
||||
first_flags = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "medium",
|
||||
"message": "多条明细需复核。",
|
||||
"item_ids": ["item-b", "item-a"],
|
||||
"basic_rule_refs": ["rule-b", "rule-a"],
|
||||
}
|
||||
]
|
||||
repeated_flags = [
|
||||
{
|
||||
**first_flags[0],
|
||||
"item_ids": ["item-a", "item-b"],
|
||||
"basic_rule_refs": ["rule-a", "rule-b"],
|
||||
}
|
||||
]
|
||||
|
||||
first = build_pre_review_decision(
|
||||
build_claim(risk_flags=first_flags),
|
||||
risk_flags=first_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
repeated = build_pre_review_decision(
|
||||
build_claim(risk_flags=repeated_flags),
|
||||
risk_flags=repeated_flags,
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert repeated["review_id"] == first["review_id"]
|
||||
assert repeated["review_context_fingerprint"] == first[
|
||||
"review_context_fingerprint"
|
||||
]
|
||||
|
||||
|
||||
def test_pre_review_identity_changes_when_dynamic_findings_change() -> None:
|
||||
claim = build_claim()
|
||||
ready = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
changed = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"message": "预审后发现同一发票已被其他单据使用。",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert changed["input_fingerprint"] == ready["input_fingerprint"]
|
||||
assert changed["review_context_fingerprint"] != ready[
|
||||
"review_context_fingerprint"
|
||||
]
|
||||
assert changed["review_id"] != ready["review_id"]
|
||||
assert changed["decision"] == "needs_fix"
|
||||
|
||||
|
||||
def test_pre_review_blocks_high_fixable_risk_and_normalizes_string_item_id() -> None:
|
||||
claim = build_claim()
|
||||
decision = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"message": "住宿金额超过标准,请修正。",
|
||||
"item_ids": "item-pre-review-decision",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert decision["decision"] == "needs_fix"
|
||||
assert decision["blocking_count"] == 1
|
||||
assert decision["findings"][0]["item_ids"] == ["item-pre-review-decision"]
|
||||
assert (
|
||||
decision["findings"][0]["remediation"]["alternative_action"]
|
||||
== "accept_standard_limit"
|
||||
)
|
||||
|
||||
|
||||
def test_pre_review_routes_budget_governance_risk_to_approval_instead_of_blocking() -> None:
|
||||
claim = build_claim()
|
||||
decision = build_pre_review_decision(
|
||||
claim,
|
||||
risk_flags=[
|
||||
{
|
||||
"source": "budget_control",
|
||||
"severity": "critical",
|
||||
"risk_domain": "budget",
|
||||
"actionability": "budget_governance",
|
||||
"message": "预算使用率超过治理阈值。",
|
||||
}
|
||||
],
|
||||
business_stage="reimbursement",
|
||||
platform_rule_set_fingerprint="rules-v1",
|
||||
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert decision["decision"] == "ready_with_review"
|
||||
assert decision["blocking_count"] == 0
|
||||
assert decision["findings"][0]["disposition"] == "review"
|
||||
assert pre_review_identity_matches(
|
||||
{**decision},
|
||||
review_id=decision["review_id"],
|
||||
input_fingerprint=decision["input_fingerprint"],
|
||||
)
|
||||
@@ -31,6 +31,7 @@ from app.services.budget import BudgetService
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_errors import ExpenseClaimPreReviewBlockedError
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
@@ -249,7 +250,7 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
|
||||
issues = service._validate_claim_for_submission(claim)
|
||||
|
||||
assert "业务地点未完善" in issues
|
||||
assert any("缺少地点" in item for item in issues)
|
||||
assert not any("缺少地点" in item for item in issues)
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
||||
@@ -3256,7 +3257,7 @@ def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path)
|
||||
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
|
||||
|
||||
|
||||
def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
def test_applicant_can_delete_own_editable_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
@@ -3271,10 +3272,10 @@ def test_non_admin_cannot_delete_own_draft_claim(monkeypatch, tmp_path) -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="只有 admin 管理员可以删除单据"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
assert deleted is not None
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||
@@ -3411,7 +3412,7 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
|
||||
def test_submit_claim_refreshes_legacy_pre_review_without_fingerprint(monkeypatch) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
name="submitter",
|
||||
@@ -3419,10 +3420,15 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fail_review(self, reviewed_claim):
|
||||
raise AssertionError("submit should reuse upload-time pre-review")
|
||||
original_review = ExpenseClaimService._run_ai_submission_review
|
||||
review_calls = 0
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
|
||||
def count_review(self, reviewed_claim):
|
||||
nonlocal review_calls
|
||||
review_calls += 1
|
||||
return original_review(self, reviewed_claim)
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", count_review)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
@@ -3462,8 +3468,18 @@ def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypa
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
|
||||
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
|
||||
assert review_calls == 1
|
||||
assert not any(
|
||||
flag.get("label") == "upload-time-warning"
|
||||
for flag in submitted.risk_flags_json
|
||||
)
|
||||
pre_review_flag = next(
|
||||
flag
|
||||
for flag in submitted.risk_flags_json
|
||||
if flag.get("source") == "ai_pre_review"
|
||||
)
|
||||
assert pre_review_flag["review_id"]
|
||||
assert pre_review_flag["input_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
|
||||
@@ -3732,7 +3748,7 @@ def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
|
||||
|
||||
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_high_risk_attachment_until_submitter_fixes_it(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -3798,19 +3814,22 @@ def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert blocked.submitted_at is None
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
finding["severity"] == "high"
|
||||
and finding["disposition"] == "fix"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_travel_route_mismatch_until_submitter_explains_it(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -3968,34 +3987,22 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||
fake_platform_route_review,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and (
|
||||
"多城市" in str(flag.get("message") or "")
|
||||
or "终点" in str(flag.get("message") or "")
|
||||
)
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
route_flags = [
|
||||
flag
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("label") or "").strip() in {"行程终点异常", "多城市行程待说明"}
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
route_findings = [
|
||||
finding
|
||||
for finding in error_info.value.review["findings"]
|
||||
if "多城市" in finding["message"] or "终点" in finding["message"]
|
||||
]
|
||||
assert route_flags
|
||||
assert all(flag.get("item_ids") for flag in route_flags)
|
||||
assert any("travel-item-2" in flag.get("item_ids", []) for flag in route_flags)
|
||||
assert not any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("label") or "").strip() == "多城市行程缺少说明中风险"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
assert route_findings
|
||||
assert any(
|
||||
"travel-item-2" in finding["item_ids"]
|
||||
for finding in route_findings
|
||||
)
|
||||
|
||||
|
||||
@@ -4147,7 +4154,7 @@ def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
||||
)
|
||||
|
||||
|
||||
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
||||
def test_submit_claim_blocks_hotel_amount_over_policy_until_standard_adjustment(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -4282,16 +4289,21 @@ def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_re
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
submitted = service.submit_claim(claim.id, current_user)
|
||||
with pytest.raises(ExpenseClaimPreReviewBlockedError) as error_info:
|
||||
service.submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
blocked = db.get(ExpenseClaim, claim.id)
|
||||
assert blocked is not None
|
||||
assert blocked.status == "draft"
|
||||
assert error_info.value.review["decision"] == "needs_fix"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and "住宿标准" in str(flag.get("message") or "")
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
finding.get("remediation", {}).get("alternative_action")
|
||||
== "accept_standard_limit"
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
assert any(
|
||||
"住宿" in finding["message"] or "酒店" in finding["message"]
|
||||
for finding in error_info.value.review["findings"]
|
||||
)
|
||||
|
||||
|
||||
@@ -4949,13 +4961,13 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
|
||||
with pytest.raises(ValueError, match="申请人本人可以删除单据"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
def test_executive_cannot_delete_submitted_claim_without_admin_role() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
@@ -4985,11 +4997,10 @@ def test_executive_can_delete_submitted_claim() -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有草稿"):
|
||||
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_direct_manager_cannot_delete_application_claim() -> None:
|
||||
@@ -6784,16 +6795,16 @@ def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == "预算管理者审批"
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "同意"
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
and flag.get("next_approval_stage") == APPLICATION_LINK_STATUS_STAGE
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert reimbursement_claim_query(db).count() == 0
|
||||
assert reimbursement_claim_query(db).count() == 1
|
||||
|
||||
|
||||
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
||||
|
||||
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
318
server/tests/test_expense_claim_tenant_and_case_stage.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.steward import StewardActionExecuteRequest, StewardTask
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
)
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.steward_action_executor import StewardActionExecutor
|
||||
from app.services.travel_reimbursement_calculator import (
|
||||
TravelReimbursementCalculatorService,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||
|
||||
|
||||
def test_ontology_draft_event_uses_context_tenant() -> None:
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="TENANT-DRAFT-EMPLOYEE",
|
||||
name="租户草稿员工",
|
||||
email="tenant-draft@example.com",
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id="tenant-draft-run",
|
||||
user_id=employee.email,
|
||||
message="2026-07-16 在上海拜访客户,交通费 32 元,保存草稿",
|
||||
ontology=OntologyParseResult(run_id="tenant-draft-run"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-expense-a",
|
||||
"name": employee.name,
|
||||
"review_action": "save_draft",
|
||||
"review_form_values": {
|
||||
"expense_type": "交通费",
|
||||
"occurred_date": "2026-07-16",
|
||||
"location": "上海",
|
||||
"reason": "客户拜访",
|
||||
"amount": "32元",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
event = db.scalar(
|
||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == result["claim_id"])
|
||||
)
|
||||
link = db.scalar(
|
||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == result["claim_id"])
|
||||
)
|
||||
assert event is not None
|
||||
assert event.tenant_id == "tenant-expense-a"
|
||||
assert link is not None
|
||||
assert link.tenant_id == "tenant-expense-a"
|
||||
|
||||
|
||||
class ReviewSubmitTenantProbe(ExpenseClaimReviewPreviewMixin):
|
||||
def __init__(self) -> None:
|
||||
self.submission_user: CurrentUserContext | None = None
|
||||
|
||||
def upsert_draft_from_ontology(self, **_kwargs):
|
||||
return {"claim_id": "tenant-review-claim", "draft_only": True}
|
||||
|
||||
def submit_claim(self, _claim_id: str, current_user: CurrentUserContext):
|
||||
self.submission_user = current_user
|
||||
return SimpleNamespace(
|
||||
id="tenant-review-claim",
|
||||
claim_no="BX-TENANT-REVIEW",
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
amount=Decimal("32.00"),
|
||||
invoice_count=1,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
|
||||
|
||||
def test_review_next_step_uses_context_tenant() -> None:
|
||||
probe = ReviewSubmitTenantProbe()
|
||||
result = probe.save_or_submit_from_ontology(
|
||||
run_id="tenant-review-run",
|
||||
user_id="tenant-review@example.com",
|
||||
message="确认提交",
|
||||
ontology=OntologyParseResult(run_id="tenant-review-run"),
|
||||
context_json={
|
||||
"review_action": "next_step",
|
||||
"tenant_id": "tenant-expense-b",
|
||||
"name": "租户预览员工",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert probe.submission_user is not None
|
||||
assert probe.submission_user.tenant_id == "tenant-expense-b"
|
||||
|
||||
|
||||
def test_review_calculators_use_context_tenant(monkeypatch) -> None:
|
||||
captured_tenants: list[str] = []
|
||||
|
||||
def fake_calculate(_self, _payload, current_user):
|
||||
captured_tenants.append(current_user.tenant_id)
|
||||
return SimpleNamespace(
|
||||
grade="P4",
|
||||
days=2,
|
||||
matched_city="上海",
|
||||
hotel_rate=Decimal("300.00"),
|
||||
hotel_amount=Decimal("600.00"),
|
||||
total_allowance_rate=Decimal("100.00"),
|
||||
allowance_amount=Decimal("200.00"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(TravelReimbursementCalculatorService, "calculate", fake_calculate)
|
||||
with build_session() as db:
|
||||
expense_service = ExpenseClaimService(db)
|
||||
expense_service._build_expense_review_preview_calculation_copy(
|
||||
user_id="tenant-calculator@example.com",
|
||||
message="2026-07-16 至 2026-07-17 去上海出差,交通费 32 元",
|
||||
ontology=OntologyParseResult(run_id="tenant-calculator-preview"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-calculator",
|
||||
"employee_grade": "P4",
|
||||
"location": "上海",
|
||||
"occurred_date": "2026-07-16",
|
||||
"amount": "32元",
|
||||
},
|
||||
)
|
||||
|
||||
payload = UserAgentRequest(
|
||||
run_id="tenant-calculator-message",
|
||||
user_id="tenant-calculator@example.com",
|
||||
message="上海出差",
|
||||
ontology=OntologyParseResult(run_id="tenant-calculator-message"),
|
||||
context_json={
|
||||
"tenant_id": "tenant-calculator",
|
||||
"grade": "P4",
|
||||
"name": "租户测算员工",
|
||||
},
|
||||
)
|
||||
UserAgentService(db)._build_travel_receipt_estimate_copy(
|
||||
payload,
|
||||
travel_receipt_state={
|
||||
"destination": "上海",
|
||||
"days": 2,
|
||||
"ticket_type_label": "火车票",
|
||||
"ticket_amount": "32",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured_tenants == ["tenant-calculator", "tenant-calculator"]
|
||||
|
||||
|
||||
def test_steward_reimbursement_context_uses_current_tenant() -> None:
|
||||
task = StewardTask(
|
||||
task_id="tenant-steward-task",
|
||||
task_type="reimbursement",
|
||||
assigned_agent="reimbursement_assistant",
|
||||
title="租户报销",
|
||||
summary="上海客户拜访交通费 32 元",
|
||||
requested_action="save_draft",
|
||||
ontology_fields={
|
||||
"expense_type": "transport",
|
||||
"time_range": "2026-07-16",
|
||||
"location": "上海",
|
||||
"reason": "客户拜访",
|
||||
"amount": "32元",
|
||||
},
|
||||
missing_fields=[],
|
||||
confirmation_required=False,
|
||||
)
|
||||
request = StewardActionExecuteRequest(
|
||||
action_type="create_reimbursement_draft",
|
||||
message="保存报销草稿",
|
||||
task=task,
|
||||
)
|
||||
current_user = CurrentUserContext(
|
||||
username="tenant-steward@example.com",
|
||||
name="租户小财管家用户",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-steward",
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
context = StewardActionExecutor(db)._build_reimbursement_context_json(
|
||||
request,
|
||||
current_user,
|
||||
)
|
||||
|
||||
assert context["tenant_id"] == "tenant-steward"
|
||||
|
||||
|
||||
def test_application_approval_keeps_case_at_approved_to_spend() -> None:
|
||||
with build_session() as db:
|
||||
department = OrganizationUnit(
|
||||
unit_code="TENANT-CASE-TRAVEL",
|
||||
name="租户差旅部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="TENANT-CASE-MANAGER",
|
||||
name="租户差旅经理",
|
||||
email="tenant-case-manager@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="TENANT-CASE-EMPLOYEE",
|
||||
name="租户差旅员工",
|
||||
email="tenant-case-employee@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([department, manager, employee])
|
||||
db.flush()
|
||||
db.add(
|
||||
BudgetAllocation(
|
||||
budget_no="BUD-TENANT-CASE-TRAVEL",
|
||||
fiscal_year=2026,
|
||||
period_type="year",
|
||||
period_key="2026",
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
cost_center=None,
|
||||
project_code=None,
|
||||
subject_code="travel",
|
||||
subject_name="差旅费",
|
||||
original_amount=Decimal("50000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
)
|
||||
)
|
||||
application = ExpenseClaim(
|
||||
claim_no="AP-TENANT-CASE-GENERATE",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code="PRJ-TENANT-CASE",
|
||||
expense_type="travel_application",
|
||||
reason="客户现场差旅",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(application)
|
||||
db.flush()
|
||||
ExpenseCaseService(db).ensure_case_for_claim(
|
||||
application,
|
||||
tenant_id="tenant-case-stage",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
application.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
tenant_id="tenant-case-stage",
|
||||
),
|
||||
opinion="业务必要,同意申请",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
expense_case = db.scalar(select(ExpenseCase))
|
||||
assert expense_case is not None
|
||||
assert expense_case.tenant_id == "tenant-case-stage"
|
||||
assert expense_case.current_stage == "approved_to_spend"
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(BusinessEvent.expense_case_id == expense_case.id)
|
||||
.order_by(BusinessEvent.occurred_at)
|
||||
).all()
|
||||
)
|
||||
assert [event.event_type for event in events] == [
|
||||
"application_approved",
|
||||
"reimbursement_draft_generated",
|
||||
]
|
||||
assert {event.tenant_id for event in events} == {"tenant-case-stage"}
|
||||
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
289
server/tests/test_expense_claim_tenant_scope.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
||||
from app.schemas.reimbursement import ExpenseClaimUpdate
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.test_helpers.db import build_in_memory_session_factory
|
||||
|
||||
|
||||
def _build_claim(*, claim_id: str, claim_no: str, employee: Employee) -> ExpenseClaim:
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=claim_no,
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id="tenant-scope-department",
|
||||
department_name="租户隔离部",
|
||||
project_code=None,
|
||||
expense_type="office",
|
||||
reason=f"{claim_no} 原始事由",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 7, 16, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
|
||||
|
||||
def _current_user(tenant_id: str) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username="same-owner@example.com",
|
||||
name="同名员工",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def test_same_identity_claims_are_isolated_by_server_tenant() -> None:
|
||||
session_factory = build_in_memory_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
id="tenant-scope-employee",
|
||||
employee_no="TENANT-SCOPE-001",
|
||||
name="同名员工",
|
||||
email="same-owner@example.com",
|
||||
)
|
||||
tenant_a_claim = _build_claim(
|
||||
claim_id="tenant-a-claim",
|
||||
claim_no="RE-TENANT-A",
|
||||
employee=employee,
|
||||
)
|
||||
tenant_b_claim = _build_claim(
|
||||
claim_id="tenant-b-claim",
|
||||
claim_no="RE-TENANT-B",
|
||||
employee=employee,
|
||||
)
|
||||
legacy_default_claim = _build_claim(
|
||||
claim_id="legacy-default-claim",
|
||||
claim_no="RE-LEGACY-DEFAULT",
|
||||
employee=employee,
|
||||
)
|
||||
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
|
||||
db.flush()
|
||||
case_service = ExpenseCaseService(db)
|
||||
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
|
||||
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
tenant_a_user = _current_user("tenant-a")
|
||||
tenant_b_user = _current_user("tenant-b")
|
||||
default_user = _current_user("default")
|
||||
|
||||
assert {claim.id for claim in service.list_claims(tenant_a_user)} == {
|
||||
tenant_a_claim.id
|
||||
}
|
||||
assert {claim.id for claim in service.list_claims(tenant_b_user)} == {
|
||||
tenant_b_claim.id
|
||||
}
|
||||
assert {claim.id for claim in service.list_claims(default_user)} == {
|
||||
legacy_default_claim.id
|
||||
}
|
||||
assert service.get_claim(tenant_b_claim.id, tenant_a_user) is None
|
||||
assert service.get_claim(tenant_a_claim.id, tenant_b_user) is None
|
||||
|
||||
assert (
|
||||
service.update_claim(
|
||||
claim_id=tenant_b_claim.id,
|
||||
payload=ExpenseClaimUpdate(reason="跨租户篡改"),
|
||||
current_user=tenant_a_user,
|
||||
)
|
||||
is None
|
||||
)
|
||||
db.expire_all()
|
||||
assert db.get(ExpenseClaim, tenant_b_claim.id).reason == "RE-TENANT-B 原始事由"
|
||||
|
||||
|
||||
def _seed_cross_tenant_risk_history(db):
|
||||
employee = Employee(
|
||||
id="tenant-history-employee",
|
||||
employee_no="TENANT-HISTORY-001",
|
||||
name="同名风险员工",
|
||||
email="same-risk-owner@example.com",
|
||||
)
|
||||
clean_claim = _build_claim(
|
||||
claim_id="tenant-a-clean-claim",
|
||||
claim_no="RE-TENANT-A-CLEAN",
|
||||
employee=employee,
|
||||
)
|
||||
risky_claim = _build_claim(
|
||||
claim_id="tenant-b-risky-claim",
|
||||
claim_no="RE-TENANT-B-RISKY",
|
||||
employee=employee,
|
||||
)
|
||||
risky_claim.risk_flags_json = [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "跨租户风险",
|
||||
"message": "该风险只属于 tenant-b。",
|
||||
}
|
||||
]
|
||||
db.add_all([employee, clean_claim, risky_claim])
|
||||
db.flush()
|
||||
case_service = ExpenseCaseService(db)
|
||||
case_service.ensure_case_for_claim(clean_claim, tenant_id="tenant-a")
|
||||
case_service.ensure_case_for_claim(risky_claim, tenant_id="tenant-b")
|
||||
db.commit()
|
||||
return clean_claim
|
||||
|
||||
|
||||
def test_cross_tenant_risk_history_does_not_pollute_pre_review(monkeypatch) -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
clean_claim = _seed_cross_tenant_risk_history(db)
|
||||
service = ExpenseClaimService(db)
|
||||
monkeypatch.setattr(service, "_resolve_claim_manager_name", lambda _claim: "直属领导")
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run_travel_policy_review",
|
||||
lambda _claim: {"flags": [], "blocking_reasons": []},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run_scene_policy_review",
|
||||
lambda _claim: {"flags": [], "blocking_reasons": []},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"evaluate_platform_risk_rules",
|
||||
lambda _claim, **_kwargs: {"flags": [], "rule_set_fingerprint": ""},
|
||||
)
|
||||
|
||||
assert service._count_recent_risky_claims(clean_claim) == 0
|
||||
pre_review = service.refresh_claim_pre_review_state(
|
||||
clean_claim,
|
||||
is_application_claim=False,
|
||||
)
|
||||
|
||||
assert pre_review is not None
|
||||
assert pre_review["decision"] == "ready"
|
||||
assert not any(
|
||||
isinstance(flag, dict) and str(flag.get("label") or "").startswith("历史风险")
|
||||
for flag in clean_claim.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_cross_tenant_risk_history_does_not_route_to_p8(monkeypatch) -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
clean_claim = _seed_cross_tenant_risk_history(db)
|
||||
monkeypatch.setattr(
|
||||
BudgetService,
|
||||
"analyze_claim_budget",
|
||||
lambda _self, _claim: {
|
||||
"score": 100,
|
||||
"rating": "pass",
|
||||
"risk_level": "low",
|
||||
"summary": "预算正常",
|
||||
"metrics": {},
|
||||
"budget_context": {"budget_applicable": False},
|
||||
},
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
assert service._count_recent_substantive_risky_claims(clean_claim) == 0
|
||||
route = service._build_approval_route_decision(
|
||||
clean_claim,
|
||||
is_application_claim=False,
|
||||
)
|
||||
|
||||
assert route["historical_risk_count"] == 0
|
||||
assert route["requires_budget_review"] is False
|
||||
assert route["route"] == "finance"
|
||||
|
||||
|
||||
def test_draft_lookup_never_returns_cross_tenant_claim_and_keeps_default_legacy() -> None:
|
||||
session_factory = build_in_memory_session_factory()
|
||||
with session_factory() as db:
|
||||
employee = Employee(
|
||||
id="tenant-draft-employee",
|
||||
employee_no="TENANT-DRAFT-001",
|
||||
name="同名草稿员工",
|
||||
email="same-draft-owner@example.com",
|
||||
)
|
||||
tenant_a_claim = _build_claim(
|
||||
claim_id="tenant-a-draft",
|
||||
claim_no="RE-TENANT-A-DRAFT",
|
||||
employee=employee,
|
||||
)
|
||||
tenant_b_claim = _build_claim(
|
||||
claim_id="tenant-b-draft",
|
||||
claim_no="RE-TENANT-B-DRAFT",
|
||||
employee=employee,
|
||||
)
|
||||
legacy_default_claim = _build_claim(
|
||||
claim_id="legacy-default-draft",
|
||||
claim_no="RE-LEGACY-DEFAULT-DRAFT",
|
||||
employee=employee,
|
||||
)
|
||||
db.add_all([employee, tenant_a_claim, tenant_b_claim, legacy_default_claim])
|
||||
db.flush()
|
||||
case_service = ExpenseCaseService(db)
|
||||
case_service.ensure_case_for_claim(tenant_a_claim, tenant_id="tenant-a")
|
||||
case_service.ensure_case_for_claim(tenant_b_claim, tenant_id="tenant-b")
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
tenant_b_ontology = OntologyParseResult(
|
||||
run_id="tenant-b-draft-lookup",
|
||||
entities=[
|
||||
OntologyEntity(
|
||||
type="expense_claim",
|
||||
value=tenant_b_claim.claim_no,
|
||||
normalized_value=tenant_b_claim.claim_no,
|
||||
confidence=1.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
empty_ontology = OntologyParseResult(run_id="tenant-draft-candidate")
|
||||
|
||||
assert (
|
||||
service._find_target_claim(
|
||||
ontology=empty_ontology,
|
||||
context_json={
|
||||
"tenant_id": "tenant-a",
|
||||
"draft_claim_id": tenant_b_claim.id,
|
||||
},
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
service._find_target_claim(
|
||||
ontology=tenant_b_ontology,
|
||||
context_json={"tenantId": "tenant-a"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
association_candidate = service._find_association_candidate(
|
||||
ontology=empty_ontology,
|
||||
context_json={
|
||||
"tenant_id": "tenant-a",
|
||||
"draft_claim_id": tenant_b_claim.id,
|
||||
},
|
||||
user_id=employee.email,
|
||||
employee=employee,
|
||||
)
|
||||
assert association_candidate is not None
|
||||
assert association_candidate.id == tenant_a_claim.id
|
||||
|
||||
legacy_by_id = service._find_target_claim(
|
||||
ontology=empty_ontology,
|
||||
context_json={
|
||||
"tenant_id": "default",
|
||||
"draft_claim_id": legacy_default_claim.id,
|
||||
},
|
||||
)
|
||||
assert legacy_by_id is not None
|
||||
assert legacy_by_id.id == legacy_default_claim.id
|
||||
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
78
server/tests/test_expense_rule_runtime_resilience.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from openpyxl.utils.exceptions import InvalidFileException
|
||||
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
AgentAssetSpreadsheetManager,
|
||||
)
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
from app.services.expense_rule_runtime_models import build_default_expense_rule_catalog
|
||||
|
||||
|
||||
def _spreadsheet_asset() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
config_json={
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_document": {"storage_key": "corrupted.xlsx"},
|
||||
},
|
||||
current_version="v1.0.0",
|
||||
name="临时损坏规则",
|
||||
)
|
||||
|
||||
|
||||
def _spreadsheet_version() -> SimpleNamespace:
|
||||
return SimpleNamespace(content="", version="v1.0.0")
|
||||
|
||||
|
||||
def _assert_unreadable_workbook_is_skipped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workbook_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
AgentAssetSpreadsheetManager,
|
||||
"resolve_storage_path",
|
||||
lambda _manager, _storage_key: workbook_path,
|
||||
)
|
||||
catalog = build_default_expense_rule_catalog()
|
||||
default_catalog = deepcopy(catalog)
|
||||
|
||||
ExpenseRuleRuntimeService(db=None)._apply_spreadsheet_runtime_payload(
|
||||
catalog,
|
||||
asset=_spreadsheet_asset(),
|
||||
version=_spreadsheet_version(),
|
||||
)
|
||||
|
||||
assert catalog == default_catalog
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.scene_policies
|
||||
|
||||
|
||||
def test_corrupted_spreadsheet_is_skipped_without_breaking_default_catalog(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
corrupted_workbook = tmp_path / "corrupted.xlsx"
|
||||
corrupted_workbook.write_bytes(b"not-an-xlsx-archive")
|
||||
|
||||
_assert_unreadable_workbook_is_skipped(monkeypatch, corrupted_workbook)
|
||||
|
||||
|
||||
def test_invalid_spreadsheet_format_is_skipped_without_breaking_catalog(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
invalid_workbook = tmp_path / "invalid.xlsx"
|
||||
invalid_workbook.touch()
|
||||
monkeypatch.setattr(
|
||||
"app.services.expense_rule_runtime.load_workbook",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
InvalidFileException("unsupported workbook format")
|
||||
),
|
||||
)
|
||||
|
||||
_assert_unreadable_workbook_is_skipped(monkeypatch, invalid_workbook)
|
||||
@@ -13,7 +13,10 @@ from app.main import create_app
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.orchestrator import OrchestratorResponse, OrchestratorTraceSummary
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.linked_reimbursement_draft_jobs import (
|
||||
_build_direct_context_json,
|
||||
_find_application_claim,
|
||||
clear_linked_reimbursement_draft_jobs_for_tests,
|
||||
)
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
@@ -150,6 +153,122 @@ def test_linked_reimbursement_draft_job_runs_after_conversation_leaves(monkeypat
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
def test_linked_job_overrides_forged_tenant_and_rejects_same_owner_cross_tenant(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
captured_contexts = []
|
||||
|
||||
def fake_run(self, payload):
|
||||
captured_contexts.append(dict(payload.context_json or {}))
|
||||
return OrchestratorResponse(
|
||||
run_id="run-linked-tenant-guard",
|
||||
conversation_id=None,
|
||||
selected_agent="user_agent",
|
||||
route_reason="验证租户边界。",
|
||||
permission_level="draft_write",
|
||||
status="succeeded",
|
||||
result={
|
||||
"message": "报销草稿已生成。",
|
||||
"draft_payload": {
|
||||
"claim_id": "tenant-guard-draft",
|
||||
"claim_no": "RE-TENANT-GUARD",
|
||||
"status": "draft",
|
||||
"expense_type": "travel",
|
||||
},
|
||||
},
|
||||
requires_confirmation=False,
|
||||
trace_summary=OrchestratorTraceSummary(
|
||||
scenario="expense",
|
||||
intent="draft",
|
||||
tool_count=1,
|
||||
failed_tool_count=0,
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OrchestratorService, "run", fake_run)
|
||||
try:
|
||||
client, _session_factory = build_client(monkeypatch)
|
||||
tenant_a_headers = {
|
||||
"x-auth-username": "same-owner@example.com",
|
||||
"x-auth-name": "Same Owner",
|
||||
"x-auth-role-codes": "user",
|
||||
"x-auth-tenant-id": "tenant-a",
|
||||
}
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/linked-reimbursement-draft-jobs",
|
||||
headers=tenant_a_headers,
|
||||
json={
|
||||
"message": "创建普通报销草稿",
|
||||
"context_json": {
|
||||
"tenant_id": "tenant-forged",
|
||||
"tenantId": "tenant-forged-camel",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert captured_contexts == [
|
||||
{
|
||||
"tenant_id": "tenant-a",
|
||||
"entry_source": "workbench-ai",
|
||||
"session_type": "expense",
|
||||
}
|
||||
]
|
||||
job_id = response.json()["job_id"]
|
||||
cross_tenant_response = client.get(
|
||||
f"/api/v1/reimbursements/linked-reimbursement-draft-jobs/{job_id}",
|
||||
headers={**tenant_a_headers, "x-auth-tenant-id": "tenant-b"},
|
||||
)
|
||||
assert cross_tenant_response.status_code == 404
|
||||
finally:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
def test_linked_application_lookup_and_context_are_tenant_scoped() -> None:
|
||||
session_factory = build_in_memory_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_employee_and_application(db)
|
||||
application = db.get(ExpenseClaim, "application-linked-draft-fast")
|
||||
assert application is not None
|
||||
ExpenseCaseService(db).ensure_case_for_claim(
|
||||
application,
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert (
|
||||
_find_application_claim(
|
||||
db,
|
||||
claim_no="AP-202606-FAST",
|
||||
tenant_id="tenant-b",
|
||||
)
|
||||
is None
|
||||
)
|
||||
resolved = _find_application_claim(
|
||||
db,
|
||||
claim_no="AP-202606-FAST",
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
assert resolved is not None
|
||||
context = _build_direct_context_json(
|
||||
db,
|
||||
{
|
||||
"tenant_id": "tenant-forged",
|
||||
"tenantId": "tenant-forged-camel",
|
||||
"review_form_values": {
|
||||
"application_claim_no": "AP-202606-FAST",
|
||||
},
|
||||
},
|
||||
tenant_id="tenant-a",
|
||||
)
|
||||
assert context["tenant_id"] == "tenant-a"
|
||||
assert "tenantId" not in context
|
||||
assert context["review_form_values"]["application_claim_id"] == application.id
|
||||
|
||||
|
||||
def test_linked_reimbursement_draft_job_uses_direct_save_path(monkeypatch) -> None:
|
||||
clear_linked_reimbursement_draft_jobs_for_tests()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ocr import OcrService
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
@@ -124,6 +125,161 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
||||
return claim, item
|
||||
|
||||
|
||||
def test_claim_pre_review_and_submit_share_structured_handshake() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-request-id": "pre-review-endpoint-ready-1",
|
||||
}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
assert review["review_id"]
|
||||
assert review["input_fingerprint"].startswith("sha256:")
|
||||
assert review["rule_set_fingerprint"].startswith("sha256:")
|
||||
|
||||
submit_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert submit_response.status_code == 200
|
||||
assert submit_response.json()["status"] == "submitted"
|
||||
with session_factory() as db:
|
||||
events = list(
|
||||
db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(BusinessEvent.aggregate_id == "claim-attachment-1")
|
||||
.order_by(BusinessEvent.occurred_at.asc())
|
||||
).all()
|
||||
)
|
||||
assert [event.event_type for event in events] == [
|
||||
"claim_pre_review_completed",
|
||||
"claim_submitted",
|
||||
]
|
||||
assert events[0].correlation_id == "pre-review-endpoint-ready-1"
|
||||
assert events[1].correlation_id == events[0].correlation_id
|
||||
assert events[1].causation_id == events[0].id
|
||||
|
||||
|
||||
def test_claim_submit_returns_structured_pre_review_conflict() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "wrong-receipt.png"
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "manual_risk",
|
||||
"severity": "high",
|
||||
"actionability": "fixable_by_submitter",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "票据与明细不一致",
|
||||
"message": "请更正费用明细或重新上传正确票据。",
|
||||
"item_ids": [item.id],
|
||||
}
|
||||
]
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers={"x-auth-username": "zhangsan@example.com"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_NEEDS_FIX"
|
||||
assert detail["review"]["decision"] == "needs_fix"
|
||||
assert detail["review"]["blocking_count"] == 1
|
||||
assert detail["review"]["findings"][0]["remediation"]["action"] == "edit_item_note"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
assert claim.submitted_at is None
|
||||
assert db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.aggregate_id == claim.id,
|
||||
BusinessEvent.event_type == "claim_submitted",
|
||||
)
|
||||
) is None
|
||||
|
||||
|
||||
def test_claim_submit_returns_changed_conflict_when_dynamic_review_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
claim, item = seed_claim(db)
|
||||
claim.invoice_count = 1
|
||||
item.invoice_id = "office-receipt.png"
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "zhangsan@example.com"}
|
||||
review_response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/pre-review",
|
||||
headers=headers,
|
||||
)
|
||||
assert review_response.status_code == 200
|
||||
review = review_response.json()["pre_review"]
|
||||
assert review["decision"] in {"ready", "ready_with_review"}
|
||||
|
||||
def changed_review(_service, _claim):
|
||||
return {
|
||||
"risk_flags": [
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"actionability": "review_decision",
|
||||
"business_stage": "reimbursement",
|
||||
"label": "新增历史风险",
|
||||
"message": "预审后发现新的历史风险记录,请确认后重试。",
|
||||
}
|
||||
],
|
||||
"rule_set_fingerprint": "rules-v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExpenseClaimService,
|
||||
"_run_ai_submission_review",
|
||||
changed_review,
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-attachment-1/submit",
|
||||
headers=headers,
|
||||
json={
|
||||
"pre_review_id": review["review_id"],
|
||||
"input_fingerprint": review["input_fingerprint"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "PRE_REVIEW_CHANGED"
|
||||
assert detail["review"]["review_id"] != review["review_id"]
|
||||
assert detail["review"]["decision"] == "ready_with_review"
|
||||
with session_factory() as db:
|
||||
claim = db.get(ExpenseClaim, "claim-attachment-1")
|
||||
assert claim is not None
|
||||
assert claim.status == "draft"
|
||||
|
||||
|
||||
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
@@ -843,7 +999,7 @@ def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path
|
||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-name": "张三",
|
||||
"x-auth-name": "Zhang San",
|
||||
"x-auth-employee-no": "E10001",
|
||||
"x-auth-role-codes": "user",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user