feat: 同步报销流程与工作台改动

This commit is contained in:
caoxiaozhu
2026-06-09 08:32:00 +00:00
parent e124e4bbcb
commit 25724c354f
64 changed files with 6518 additions and 687 deletions

View File

@@ -250,6 +250,45 @@ class ExpenseClaimAccessPolicy:
return role_code
return BUDGET_MONITOR_ROLE_CODE
@staticmethod
def resolve_claim_finance_owner_name(claim: ExpenseClaim) -> str:
employee = claim.employee
if employee is not None and employee.finance_owner_name:
return str(employee.finance_owner_name).strip()
return ""
def resolve_finance_approver(self, claim: ExpenseClaim) -> Employee | None:
claim_employee_id = str(claim.employee_id or "").strip()
base_stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.where(Employee.roles.any(Role.role_code == "finance"))
)
if claim_employee_id:
base_stmt = base_stmt.where(Employee.id != claim_employee_id)
finance_owner_name = self.resolve_claim_finance_owner_name(claim)
if finance_owner_name:
named_finance = self.db.scalar(
base_stmt
.where(Employee.name == finance_owner_name)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if named_finance is not None:
return named_finance
owner_matched_finance = self.db.scalar(
base_stmt
.where(func.lower(func.coalesce(Employee.finance_owner_name, "")) == finance_owner_name.lower())
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
if owner_matched_finance is not None:
return owner_matched_finance
return self.db.scalar(base_stmt.order_by(Employee.name.asc(), Employee.employee_no.asc()).limit(1))
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
@@ -269,9 +308,25 @@ class ExpenseClaimAccessPolicy:
)
return claim
def attach_finance_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
if claim is None:
return None
if str(claim.approval_stage or "").strip() != FINANCE_APPROVAL_STAGE:
return claim
finance_approver = self.resolve_finance_approver(claim)
if finance_approver is not None and finance_approver.name:
setattr(claim, "finance_approver_name", str(finance_approver.name).strip())
return claim
def attach_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
self.attach_budget_approval_snapshot(claim)
self.attach_finance_approval_snapshot(claim)
return claim
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
for claim in claims:
self.attach_budget_approval_snapshot(claim)
self.attach_approval_snapshot(claim)
return claims
@staticmethod
@@ -647,6 +702,11 @@ class ExpenseClaimAccessPolicy:
*,
include_approval_scope: bool = False,
) -> Any:
if current_user.is_admin:
if include_approval_scope:
return stmt
return stmt.where(~self.build_archived_claim_condition())
conditions = self.build_personal_claim_conditions(current_user)
role_codes = self.normalize_role_codes(current_user)

View File

@@ -17,6 +17,7 @@ from app.services.expense_claim_risk_stage import (
class ExpenseClaimApprovalRoutingMixin:
_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = Decimal("90.00")
_BUDGET_REVIEW_RATINGS = {"block"}
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
@@ -63,7 +64,11 @@ class ExpenseClaimApprovalRoutingMixin:
) -> dict[str, Any]:
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
budget_reasons = self._collect_budget_route_reasons(budget_result)
budget_reasons = (
self._collect_application_budget_route_reasons(budget_result)
if is_application_claim
else self._collect_budget_route_reasons(budget_result)
)
current_risk_reasons = self._collect_current_route_risk_reasons(
claim.risk_flags_json,
business_stage=business_stage,
@@ -75,7 +80,9 @@ class ExpenseClaimApprovalRoutingMixin:
else []
)
reasons = self._dedupe_reasons(
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
budget_reasons
if is_application_claim
else [*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
)
requires_budget_review = bool(reasons)
route = (
@@ -86,11 +93,18 @@ class ExpenseClaimApprovalRoutingMixin:
else "finance"
)
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
if is_application_claim:
message = (
"系统根据预算占用阈值判断,该申请单达到 90% 预算复核线,需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算占用阈值判断,该申请单未达到 90% 预算复核线,可跳过预算管理者复核。"
)
else:
message = (
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
if requires_budget_review
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
)
return with_risk_business_stage(
{
@@ -136,6 +150,20 @@ class ExpenseClaimApprovalRoutingMixin:
reasons.append(f"预计超预算 {over_budget_amount}")
return self._dedupe_reasons(reasons)
def _collect_application_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
if over_budget_amount > Decimal("0.00"):
return [f"预计超预算 {over_budget_amount}"]
after_usage_rate = self._decimal(metrics.get("after_usage_rate"))
claim_amount_ratio = self._decimal(metrics.get("claim_amount_ratio"))
budget_usage_rate = max(after_usage_rate, claim_amount_ratio)
if budget_usage_rate >= self._APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD:
return [f"审批后预算占用达到 {budget_usage_rate}%,触发 90% 预算复核线"]
return []
def _collect_current_route_risk_reasons(
self,
risk_flags: list[Any] | None,

View File

@@ -352,9 +352,15 @@ class ExpenseClaimAttachmentOperationsMixin:
self._ensure_draft_claim(claim)
self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim)
previous_invoice_id = str(item.invoice_id or "").strip()
previous_name = self._attachment_presentation.resolve_display_name(item.invoice_id)
self._attachment_storage.delete_item_files(item)
item.invoice_id = None
claim.risk_flags_json = self._remove_deleted_attachment_risk_flags(
claim.risk_flags_json,
item_id=item.id,
invoice_id=previous_invoice_id,
)
self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
@@ -379,6 +385,36 @@ class ExpenseClaimAttachmentOperationsMixin:
"attachment": None,
}
@staticmethod
def _remove_deleted_attachment_risk_flags(
risk_flags: Any,
*,
item_id: str | None,
invoice_id: str | None,
) -> list[Any]:
normalized_item_id = str(item_id or "").strip()
normalized_invoice_id = str(invoice_id or "").strip()
cleaned_flags: list[Any] = []
for flag in list(risk_flags or []):
if not isinstance(flag, dict):
cleaned_flags.append(flag)
continue
source = str(flag.get("source") or "").strip()
if source != "attachment_analysis":
cleaned_flags.append(flag)
continue
flag_item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
flag_invoice_id = str(flag.get("invoice_id") or flag.get("invoiceId") or "").strip()
matches_deleted_item = bool(normalized_item_id and flag_item_id == normalized_item_id)
matches_deleted_invoice = bool(normalized_invoice_id and flag_invoice_id == normalized_invoice_id)
if matches_deleted_item or matches_deleted_invoice:
continue
cleaned_flags.append(flag)
return cleaned_flags
def _get_claim_item_or_raise(
self,
*,

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
@@ -104,7 +105,7 @@ class ExpenseClaimBudgetFlowMixin:
else flag
for flag in next_flags
]
return [*list(risk_flags or []), *enriched_flags]
return dedupe_budget_risk_flags([*list(risk_flags or []), *enriched_flags])
@staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
_DEDUPED_BUDGET_RISK_EVENT_TYPES = {
"budget_frozen",
"budget_insufficient",
"budget_missing",
"budget_warning",
}
def dedupe_budget_risk_flags(flags: list[Any] | None) -> list[Any]:
"""Collapse repeated budget risk warnings while preserving non-risk audit flags."""
deduped: list[Any] = []
key_to_index: dict[tuple[str, str, str, str], int] = {}
for flag in list(flags or []):
key = budget_risk_flag_key(flag)
if key is None:
deduped.append(flag)
continue
existing_index = key_to_index.get(key)
if existing_index is None:
key_to_index[key] = len(deduped)
deduped.append(flag)
continue
deduped[existing_index] = flag
return deduped
def budget_risk_flag_key(flag: Any) -> tuple[str, str, str, str] | None:
if not isinstance(flag, dict):
return None
source = str(flag.get("source") or "").strip()
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip()
if source != "budget_control" or event_type not in _DEDUPED_BUDGET_RISK_EVENT_TYPES:
return None
allocation_key = str(flag.get("allocation_id") or flag.get("allocationId") or "").strip()
budget_no = str(flag.get("budget_no") or flag.get("budgetNo") or "").strip()
subject_code = str(flag.get("subject_code") or flag.get("subjectCode") or "").strip()
return (source, event_type, allocation_key or budget_no, subject_code)

View File

@@ -130,7 +130,10 @@ class ExpenseClaimRiskReviewMixin(
attention_reasons.extend(scene_policy_review["blocking_reasons"])
review_flags.extend(scene_policy_review["flags"])
platform_risk_review = self.evaluate_platform_risk_rules(claim)
platform_risk_review = self.evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
attention_reasons.extend(platform_risk_review["blocking_reasons"])
platform_risk_flags = list(platform_risk_review["flags"])
review_flags.extend(platform_risk_flags)

View File

@@ -204,6 +204,7 @@ class ExpenseClaimService(
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
@@ -217,6 +218,7 @@ class ExpenseClaimService(
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
@@ -230,6 +232,7 @@ class ExpenseClaimService(
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
@@ -243,12 +246,13 @@ class ExpenseClaimService(
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.where(ExpenseClaim.id == claim_id)
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
return self._access_policy.attach_approval_snapshot(self.db.scalar(stmt))
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None:
@@ -1019,5 +1023,3 @@ class ExpenseClaimService(

View File

@@ -248,6 +248,7 @@ class OcrService:
return "|".join(
[
self.settings.ocr_language,
self.settings.ocr_device,
self.settings.ocr_text_detection_model,
self.settings.ocr_text_recognition_model,
digest,
@@ -333,6 +334,9 @@ class OcrService:
"--text-recognition-model",
self.settings.ocr_text_recognition_model,
]
configured_device = str(self.settings.ocr_device or "").strip()
if configured_device:
command.extend(["--device", configured_device])
for path in input_paths:
command.extend(["--input", str(path)])

View File

@@ -52,6 +52,8 @@ ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"employee_no": ("employeeNo",),
"employee_position": ("position", "employeePosition"),
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
"finance_owner_name": ("financeOwnerName",),
"finance_approver_name": ("financeApproverName",),
}
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
@@ -66,7 +68,6 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"control_action",
"employee_location",
"employee_risk_profile",
"finance_owner_name",
"document_id",
"application_claim_id",
"application_claim_no",

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.db.base import Base
@@ -114,6 +114,7 @@ class SystemDashboardService:
succeeded_runs = sum(1 for run in runs if self._is_success(run.status))
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
active_sessions = [item for item in sessions if str(item.status or "") == "active"]
online_user_count = len(self._unique_session_identities(active_sessions))
return SystemDashboardRead(
window_days=window_days,
@@ -122,7 +123,7 @@ class SystemDashboardService:
totals={
"toolCalls": len(tool_calls),
"modelTokens": total_tokens,
"onlineUsers": len(active_sessions),
"onlineUsers": online_user_count,
"avgOnlineMinutes": self._average_session_minutes(sessions, now),
"executionSuccessRate": self._percent(succeeded_runs, len(runs)),
"positiveFeedback": positive_feedback,
@@ -132,7 +133,7 @@ class SystemDashboardService:
"modelTokensChange": self._change_percent(total_tokens, previous_tokens),
},
agent_daily_ratio=self._agent_daily_ratio(labels, tool_calls),
login_wave=self._login_wave(sessions),
login_wave=self._login_wave(sessions, start, now),
token_daily_wave=self._token_daily_wave(labels, token_records),
user_token_usage=self._user_token_usage(token_records, user_names),
accuracy_comparison=self._accuracy_comparison(tool_calls),
@@ -215,7 +216,14 @@ class SystemDashboardService:
def _fetch_sessions(self, start: datetime) -> list[UserSessionMetric]:
stmt = (
select(UserSessionMetric)
.where(UserSessionMetric.login_at >= start)
.where(
or_(
UserSessionMetric.login_at >= start,
UserSessionMetric.logout_at >= start,
UserSessionMetric.last_activity_at >= start,
UserSessionMetric.status == "active",
)
)
.order_by(UserSessionMetric.login_at.asc())
)
return list(self.db.scalars(stmt).all())
@@ -258,19 +266,43 @@ class SystemDashboardService:
"series": ratio_series,
}
def _login_wave(self, sessions: list[UserSessionMetric]) -> dict[str, Any]:
labels = [f"{hour:02d}:00" for hour in range(8, 21)]
login_users = [0 for _ in labels]
def _login_wave(
self,
sessions: list[UserSessionMetric],
start: datetime,
now: datetime,
) -> dict[str, Any]:
labels = [f"{hour:02d}:00" for hour in range(24)]
online_users = [set[str]() for _ in labels]
interactions = [0 for _ in labels]
index = {label: idx for idx, label in enumerate(labels)}
window_start = self._as_utc(start)
window_end = self._as_utc(now)
for session in sessions:
hour = self._as_utc(session.login_at).hour
label = f"{hour:02d}:00"
if label not in index:
login_at = self._as_utc(session.login_at)
end_at = self._session_end_at(session, now)
if end_at < window_start or login_at > window_end:
continue
login_users[index[label]] += 1
interactions[index[label]] += max(0, int(session.activity_event_count or 0))
return {"labels": labels, "loginUsers": login_users, "interactions": interactions}
identity = self._session_identity(session)
overlap_start = max(login_at, window_start)
overlap_end = min(end_at, window_end)
cursor = overlap_start.replace(minute=0, second=0, microsecond=0)
while cursor <= overlap_end:
label = f"{cursor.hour:02d}:00"
online_users[index[label]].add(identity)
cursor += timedelta(hours=1)
login_label = f"{login_at.hour:02d}:00"
if login_at >= window_start and login_label in index:
interactions[index[login_label]] += max(0, int(session.activity_event_count or 0))
return {
"labels": labels,
"loginUsers": [len(items) for items in online_users],
"interactions": interactions,
}
def _token_daily_wave(self, labels: list[str], records: list[dict[str, Any]]) -> dict[str, Any]:
input_tokens = [0 for _ in labels]
@@ -547,12 +579,28 @@ class SystemDashboardService:
if int(session.duration_ms or 0) > 0:
return max(0, int(session.duration_ms or 0))
login_at = self._as_utc(session.login_at)
end_at = self._as_utc(session.logout_at or session.last_activity_at or now)
end_at = self._session_end_at(session, now)
try:
return max(0, min(int((end_at - login_at).total_seconds() * 1000), 24 * 60 * 60 * 1000))
except TypeError:
return 0
def _session_end_at(self, session: UserSessionMetric, now: datetime) -> datetime:
if str(session.status or "").strip().lower() == "active":
return self._as_utc(now)
return self._as_utc(session.logout_at or session.last_activity_at or session.login_at or now)
def _unique_session_identities(self, sessions: list[UserSessionMetric]) -> set[str]:
return {self._session_identity(item) for item in sessions}
@staticmethod
def _session_identity(session: UserSessionMetric) -> str:
for value in (session.username, session.email, session.employee_no, session.display_name):
normalized = str(value or "").strip().lower()
if normalized:
return normalized
return str(session.session_id or "").strip().lower()
@staticmethod
def _date_labels(start_date: date, days: int) -> list[str]:
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]