feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View 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",
)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View 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 {}

View File

@@ -296,6 +296,7 @@ class ExpenseClaimApprovalFlowMixin:
},
expense_case=expense_case,
relation_type="generated_reimbursement",
update_case_state=False,
)
self.db.commit()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 ""),

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 ""),
},
)

View 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()

View File

@@ -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:

View File

@@ -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 []))

View 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}"

View 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

View 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))

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"

View File

@@ -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(

View File

@@ -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(

View File

@@ -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")

View 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"],
)

View File

@@ -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:

View 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"}

View 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

View 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)

View File

@@ -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()

View File

@@ -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",
},