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: