feat: 同步报销流程与工作台改动
This commit is contained in:
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
36
server/scripts/bootstrap_paddleocr_gpu.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OCR_VENV_DIR="${OCR_VENV_DIR:-${ROOT_DIR}/.venv-ocr312}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PADDLEPADDLE_GPU_VERSION="${PADDLEPADDLE_GPU_VERSION:-3.3.0}"
|
||||
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
|
||||
PADDLE_GPU_INDEX_URL="${PADDLE_GPU_INDEX_URL:-https://www.paddlepaddle.org.cn/packages/stable/cu126/}"
|
||||
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "python3.12 不存在,请先安装 Python 3.12。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils
|
||||
|
||||
rm -rf "${OCR_VENV_DIR}"
|
||||
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip
|
||||
"${OCR_VENV_DIR}/bin/pip" install \
|
||||
"paddlepaddle-gpu==${PADDLEPADDLE_GPU_VERSION}" \
|
||||
-i "${PADDLE_GPU_INDEX_URL}"
|
||||
"${OCR_VENV_DIR}/bin/pip" install "paddleocr==${PADDLEOCR_VERSION}"
|
||||
|
||||
"${OCR_VENV_DIR}/bin/python" - <<'PY'
|
||||
import paddle
|
||||
|
||||
print("PaddlePaddle:", paddle.__version__)
|
||||
print("CUDA compiled:", paddle.is_compiled_with_cuda())
|
||||
print("CUDA device count:", paddle.device.cuda.device_count())
|
||||
paddle.utils.run_check()
|
||||
PY
|
||||
|
||||
echo "PaddleOCR GPU runtime ${PADDLEOCR_VERSION} 已安装到 ${OCR_VENV_DIR}"
|
||||
@@ -21,6 +21,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--lang", default="ch")
|
||||
parser.add_argument("--text-detection-model", default="PP-OCRv5_mobile_det")
|
||||
parser.add_argument("--text-recognition-model", default="PP-OCRv5_mobile_rec")
|
||||
parser.add_argument("--device", default=os.environ.get("OCR_DEVICE", ""))
|
||||
parser.add_argument("--enable-mkldnn", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -100,16 +101,20 @@ def build_document(input_path: str, results: list[Any]) -> dict[str, Any]:
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ocr = PaddleOCR(
|
||||
text_detection_model_name=args.text_detection_model,
|
||||
text_recognition_model_name=args.text_recognition_model,
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
lang=args.lang,
|
||||
ocr_options = {
|
||||
"text_detection_model_name": args.text_detection_model,
|
||||
"text_recognition_model_name": args.text_recognition_model,
|
||||
"use_doc_orientation_classify": False,
|
||||
"use_doc_unwarping": False,
|
||||
"use_textline_orientation": False,
|
||||
"lang": args.lang,
|
||||
# PaddlePaddle 3.3.x CPU oneDNN can fail on PP-OCRv5 static inference.
|
||||
enable_mkldnn=args.enable_mkldnn,
|
||||
)
|
||||
"enable_mkldnn": args.enable_mkldnn,
|
||||
}
|
||||
configured_device = str(args.device or "").strip()
|
||||
if configured_device:
|
||||
ocr_options["device"] = configured_device
|
||||
ocr = PaddleOCR(**ocr_options)
|
||||
|
||||
documents = []
|
||||
for input_path in args.inputs:
|
||||
|
||||
@@ -19,25 +19,25 @@ ONLYOFFICE_FIELD_NAMES = {
|
||||
|
||||
_settings_cache: Settings | None = None
|
||||
_settings_cache_signature: tuple[tuple[str, bool, int | None, int | None], ...] | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=DEFAULT_ENV_FILES,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
||||
app_env: str = Field(default="local", alias="APP_ENV")
|
||||
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
||||
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
||||
|
||||
company_name: str = Field(default="", alias="COMPANY_NAME")
|
||||
company_code: str = Field(default="", alias="COMPANY_CODE")
|
||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||
|
||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||
|
||||
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
|
||||
app_env: str = Field(default="local", alias="APP_ENV")
|
||||
app_debug: bool = Field(default=True, alias="APP_DEBUG")
|
||||
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
|
||||
|
||||
company_name: str = Field(default="", alias="COMPANY_NAME")
|
||||
company_code: str = Field(default="", alias="COMPANY_CODE")
|
||||
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
|
||||
|
||||
web_host: str = Field(default="0.0.0.0", alias="WEB_HOST")
|
||||
web_port: int = Field(default=5173, alias="WEB_PORT")
|
||||
app_host: str = Field(default="0.0.0.0", alias="SERVER_HOST")
|
||||
app_port: int = Field(default=8000, alias="SERVER_PORT")
|
||||
@@ -48,21 +48,21 @@ class Settings(BaseSettings):
|
||||
alias="BACKGROUND_SCHEDULERS_ENABLED",
|
||||
)
|
||||
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
|
||||
|
||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||
|
||||
|
||||
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
|
||||
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
|
||||
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
|
||||
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
|
||||
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
|
||||
|
||||
database_url: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
|
||||
sqlalchemy_pool_size: int = Field(default=10, alias="SQLALCHEMY_POOL_SIZE")
|
||||
sqlalchemy_max_overflow: int = Field(default=20, alias="SQLALCHEMY_MAX_OVERFLOW")
|
||||
sqlalchemy_pool_timeout: int = Field(default=30, alias="SQLALCHEMY_POOL_TIMEOUT")
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
vite_api_base_url: str = Field(
|
||||
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||
)
|
||||
@@ -77,6 +77,7 @@ class Settings(BaseSettings):
|
||||
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||
storage_root_dir: str = Field(default="storage", alias="STORAGE_ROOT_DIR")
|
||||
ocr_python_bin: str = Field(default="", alias="OCR_PYTHON_BIN")
|
||||
ocr_device: str = Field(default="", alias="OCR_DEVICE")
|
||||
ocr_timeout_seconds: int = Field(default=180, alias="OCR_TIMEOUT_SECONDS")
|
||||
ocr_max_file_size_mb: int = Field(default=20, alias="OCR_MAX_FILE_SIZE_MB")
|
||||
ocr_max_concurrent_workers: int = Field(default=1, alias="OCR_MAX_CONCURRENT_WORKERS")
|
||||
@@ -98,7 +99,7 @@ class Settings(BaseSettings):
|
||||
def resolved_database_url(self) -> str:
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
|
||||
|
||||
return (
|
||||
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
@@ -68,8 +68,16 @@ class ExpenseClaim(Base):
|
||||
return None
|
||||
if self.employee.manager is not None and self.employee.manager.name:
|
||||
return str(self.employee.manager.name).strip() or None
|
||||
if self.employee.organization_unit is not None and self.employee.organization_unit.manager_name:
|
||||
return str(self.employee.organization_unit.manager_name).strip() or None
|
||||
return None
|
||||
|
||||
@property
|
||||
def finance_owner_name(self) -> str | None:
|
||||
if self.employee is None or not self.employee.finance_owner_name:
|
||||
return None
|
||||
return str(self.employee.finance_owner_name).strip() or None
|
||||
|
||||
@property
|
||||
def role_labels(self) -> list[str]:
|
||||
if self.employee is None or not self.employee.roles:
|
||||
|
||||
@@ -4,7 +4,9 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
|
||||
|
||||
|
||||
class ReimbursementCreate(BaseModel):
|
||||
@@ -147,6 +149,8 @@ class ExpenseClaimRead(BaseModel):
|
||||
employee_position: str | None = None
|
||||
employee_grade: str | None = None
|
||||
manager_name: str | None = None
|
||||
finance_owner_name: str | None = None
|
||||
finance_approver_name: str | None = None
|
||||
budget_approver_name: str | None = None
|
||||
budget_approver_grade: str | None = None
|
||||
budget_approver_role_code: str | None = None
|
||||
@@ -167,6 +171,13 @@ class ExpenseClaimRead(BaseModel):
|
||||
updated_at: datetime
|
||||
items: list[ExpenseClaimItemRead] = Field(default_factory=list)
|
||||
|
||||
@field_validator("risk_flags_json", mode="before")
|
||||
@classmethod
|
||||
def dedupe_budget_risk_flags_for_read(cls, value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return dedupe_budget_risk_flags(value)
|
||||
return []
|
||||
|
||||
|
||||
class ExpenseClaimActionResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
47
server/src/app/services/expense_claim_budget_risk_flags.py
Normal file
47
server/src/app/services/expense_claim_budget_risk_flags.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -234,6 +234,124 @@ def test_budget_warning_application_still_skips_budget_manager_when_not_over_bud
|
||||
)
|
||||
|
||||
|
||||
def test_application_routes_to_budget_manager_when_usage_reaches_90_percent() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="OVER-90-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-OVER-90",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("9500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
routed = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert routed is not None
|
||||
assert routed.status == "submitted"
|
||||
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
and flag.get("requires_budget_review") is True
|
||||
and flag.get("route") == "budget_manager"
|
||||
and any("90%" in item or "90" in item for item in flag.get("reasons", []))
|
||||
for flag in routed.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_application_stage_risk_under_90_percent_does_not_route_to_budget_manager() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="RISK-APP")
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
amount=Decimal("10000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260530-RISK-UNDER-90",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="客户现场支持",
|
||||
location="上海",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "申请信息风险",
|
||||
"message": "申请事由需要领导关注。",
|
||||
"business_stage": "expense_application",
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim.id,
|
||||
CurrentUserContext(
|
||||
username=manager.email,
|
||||
name=manager.name,
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
),
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
route_flag = [
|
||||
flag
|
||||
for flag in approved.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("source") == "approval_routing"
|
||||
][0]
|
||||
assert route_flag["requires_budget_review"] is False
|
||||
assert route_flag["route"] == "approval_done"
|
||||
assert route_flag["current_risk_count"] == 1
|
||||
|
||||
|
||||
def test_application_route_ignores_reimbursement_stage_current_risks() -> None:
|
||||
with build_session() as db:
|
||||
department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE")
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.schemas.reimbursement import (
|
||||
)
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
@@ -119,6 +120,42 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
|
||||
base_warning = {
|
||||
"source": "budget_control",
|
||||
"event_type": "budget_warning",
|
||||
"severity": "medium",
|
||||
"label": "预算接近预警线",
|
||||
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 98.00%,已达到预警线 80.00%。",
|
||||
"budget_no": "SIM-BUD-2026-R0048",
|
||||
"allocation_id": "allocation-0048",
|
||||
"subject_code": "travel",
|
||||
"created_at": "2026-06-03T10:00:00+00:00",
|
||||
}
|
||||
latest_warning = {
|
||||
**base_warning,
|
||||
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%。",
|
||||
"created_at": "2026-06-03T10:01:00+00:00",
|
||||
}
|
||||
|
||||
flags = ExpenseClaimBudgetFlowMixin._append_budget_flags(
|
||||
[base_warning],
|
||||
latest_warning,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
|
||||
warnings = [
|
||||
flag
|
||||
for flag in flags
|
||||
if isinstance(flag, dict)
|
||||
and flag.get("source") == "budget_control"
|
||||
and flag.get("event_type") == "budget_warning"
|
||||
]
|
||||
assert len(warnings) == 1
|
||||
assert "99.27%" in warnings[0]["message"]
|
||||
assert warnings[0]["business_stage"] == "reimbursement"
|
||||
|
||||
|
||||
def _count_claims(db: Session) -> int:
|
||||
return int(db.query(ExpenseClaim).count())
|
||||
|
||||
@@ -2360,6 +2397,112 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-hotel-risk@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
def fake_recognize(
|
||||
self,
|
||||
files: list[tuple[str, bytes, str | None]],
|
||||
) -> OcrRecognizeBatchRead:
|
||||
return OcrRecognizeBatchRead(
|
||||
total_file_count=1,
|
||||
success_count=1,
|
||||
documents=[
|
||||
OcrRecognizeDocumentRead(
|
||||
filename="hotel-risk.png",
|
||||
media_type="image/png",
|
||||
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
|
||||
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="hotel_invoice",
|
||||
document_type_label="酒店住宿票据",
|
||||
scene_code="hotel",
|
||||
scene_label="住宿票据",
|
||||
document_fields=[
|
||||
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
|
||||
{"key": "amount", "label": "金额", "value": "800元"},
|
||||
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E7402",
|
||||
name="张三",
|
||||
email="emp-hotel-risk@example.com",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.reason = "北京客户现场出差"
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_type = "hotel"
|
||||
claim.items[0].item_reason = "北京住宿"
|
||||
claim.items[0].item_location = "北京"
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
upload_payload = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="hotel-risk.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert upload_payload is not None
|
||||
assert any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in upload_payload["claim_risk_flags"]
|
||||
)
|
||||
|
||||
delete_payload = service.delete_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert delete_payload is not None
|
||||
assert delete_payload["invoice_id"] is None
|
||||
assert not any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in delete_payload["claim_risk_flags"]
|
||||
)
|
||||
assert not any(
|
||||
"高风险附件" in str(flag.get("message") or "")
|
||||
for flag in delete_payload["claim_risk_flags"]
|
||||
if isinstance(flag, dict)
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.invoice_count == 0
|
||||
assert claim.items[0].invoice_id is None
|
||||
assert not any(
|
||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
@@ -3741,6 +3884,101 @@ def test_list_claims_returns_company_reimbursements_for_finance_document_center(
|
||||
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
||||
|
||||
|
||||
def test_list_claims_returns_all_active_documents_for_admin_document_center() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="admin",
|
||||
name="admin",
|
||||
role_codes=["admin"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="AP-ADMIN-DRAFT",
|
||||
employee_name="Applicant A",
|
||||
department_name="Tech",
|
||||
project_code="PRJ-APP",
|
||||
expense_type="travel_application",
|
||||
reason="Travel application draft",
|
||||
location="Shanghai",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="draft",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-ADMIN-LINKING",
|
||||
employee_name="Applicant B",
|
||||
department_name="Tech",
|
||||
project_code="PRJ-APP",
|
||||
expense_type="travel_application",
|
||||
reason="Travel application approved",
|
||||
location="Beijing",
|
||||
amount=Decimal("2200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage=APPLICATION_LINK_STATUS_STAGE,
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ADMIN-DRAFT",
|
||||
employee_name="Applicant C",
|
||||
department_name="Finance",
|
||||
project_code="PRJ-EXP",
|
||||
expense_type="office",
|
||||
reason="Office draft",
|
||||
location="Hangzhou",
|
||||
amount=Decimal("300.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 13, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="draft",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ADMIN-ARCHIVED",
|
||||
employee_name="Applicant D",
|
||||
department_name="Finance",
|
||||
project_code="PRJ-EXP",
|
||||
expense_type="office",
|
||||
reason="Archived reimbursement",
|
||||
location="Hangzhou",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 14, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 14, 10, 0, tzinfo=UTC),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
|
||||
archived_nos = {
|
||||
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
}
|
||||
|
||||
assert "AP-ADMIN-DRAFT" in claim_nos
|
||||
assert "AP-ADMIN-LINKING" in claim_nos
|
||||
assert "EXP-ADMIN-DRAFT" in claim_nos
|
||||
assert "EXP-ADMIN-ARCHIVED" not in claim_nos
|
||||
assert "EXP-ADMIN-ARCHIVED" in archived_nos
|
||||
|
||||
|
||||
def test_list_claims_limits_executive_to_personal_records() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive@example.com",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -88,6 +89,46 @@ print("__OCR_JSON__=" + json.dumps(payload, ensure_ascii=False))
|
||||
assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"]
|
||||
|
||||
|
||||
def test_ocr_service_passes_configured_device_to_worker(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured_commands: list[list[str]] = []
|
||||
|
||||
def fake_run(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
text: bool,
|
||||
timeout: int,
|
||||
check: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured_commands.append(command)
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=0,
|
||||
stdout='__OCR_JSON__={"engine":"paddleocr_mobile","model":"PP-OCRv5_mobile","documents":[]}\n',
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("OCR_DEVICE", "gpu:0")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
try:
|
||||
payload = OcrService()._invoke_worker(
|
||||
python_bin="python",
|
||||
worker_path="worker.py",
|
||||
input_paths=[tmp_path / "invoice.png"],
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
assert payload["engine"] == "paddleocr_mobile"
|
||||
command = captured_commands[0]
|
||||
device_index = command.index("--device")
|
||||
assert command[device_index + 1] == "gpu:0"
|
||||
|
||||
|
||||
def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -113,6 +113,135 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
|
||||
return claim, item
|
||||
|
||||
|
||||
def test_claim_read_uses_organization_manager_and_dedupes_budget_warnings() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
department = OrganizationUnit(
|
||||
id="dept-org-manager",
|
||||
unit_code="DEPT-ORG-MANAGER",
|
||||
name="交付部",
|
||||
manager_name="王总",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-org-manager",
|
||||
employee_no="E30001",
|
||||
name="赵六",
|
||||
email="zhaoliu@example.com",
|
||||
organization_unit=department,
|
||||
position="实施顾问",
|
||||
grade="P5",
|
||||
finance_owner_name="Wang Finance",
|
||||
)
|
||||
duplicated_warning = {
|
||||
"source": "budget_control",
|
||||
"event_type": "budget_warning",
|
||||
"severity": "medium",
|
||||
"label": "预算接近预警线",
|
||||
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%。",
|
||||
"budget_no": "SIM-BUD-2026-R0048",
|
||||
"allocation_id": "allocation-0048",
|
||||
"subject_code": "travel",
|
||||
}
|
||||
claim = ExpenseClaim(
|
||||
id="claim-org-manager",
|
||||
claim_no="EXP-202606-ORG-MGR",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_id=department.id,
|
||||
department_name=department.name,
|
||||
project_code=None,
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("880.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[
|
||||
{**duplicated_warning, "created_at": "2026-06-03T10:00:00+00:00"},
|
||||
{**duplicated_warning, "created_at": "2026-06-03T10:01:00+00:00"},
|
||||
],
|
||||
)
|
||||
db.add_all([department, employee, claim])
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "zhaoliu@example.com"}
|
||||
response = client.get("/api/v1/reimbursements/claims/claim-org-manager", headers=headers)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["manager_name"] == "王总"
|
||||
assert payload["finance_owner_name"] == "Wang Finance"
|
||||
budget_warnings = [
|
||||
flag
|
||||
for flag in payload["risk_flags_json"]
|
||||
if flag.get("source") == "budget_control" and flag.get("event_type") == "budget_warning"
|
||||
]
|
||||
assert len(budget_warnings) == 1
|
||||
assert budget_warnings[0]["message"] == duplicated_warning["message"]
|
||||
|
||||
|
||||
def test_claim_read_attaches_finance_approver_name_for_finance_stage() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
finance_role = Role(
|
||||
id="role-finance-reader",
|
||||
role_code="finance",
|
||||
name="财务",
|
||||
description="可处理财务复核任务",
|
||||
)
|
||||
applicant = Employee(
|
||||
id="emp-finance-stage-applicant",
|
||||
employee_no="E30002",
|
||||
name="钱七",
|
||||
email="qianqi@example.com",
|
||||
position="实施顾问",
|
||||
grade="P5",
|
||||
finance_owner_name="Wang Finance Group",
|
||||
)
|
||||
finance_user = Employee(
|
||||
id="emp-finance-stage-approver",
|
||||
employee_no="F30002",
|
||||
name="Wang Finance",
|
||||
email="wang.finance@example.com",
|
||||
position="财务专员",
|
||||
grade="P6",
|
||||
finance_owner_name="Wang Finance Group",
|
||||
roles=[finance_role],
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-finance-stage-reader",
|
||||
claim_no="EXP-202606-FINANCE-MGR",
|
||||
employee_id=applicant.id,
|
||||
employee_name=applicant.name,
|
||||
department_id=None,
|
||||
department_name="交付部",
|
||||
project_code=None,
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("880.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 6, 3, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 6, 3, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([finance_role, applicant, finance_user, claim])
|
||||
db.commit()
|
||||
|
||||
headers = {"x-auth-username": "qianqi@example.com"}
|
||||
response = client.get("/api/v1/reimbursements/claims/claim-finance-stage-reader", headers=headers)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["finance_owner_name"] == "Wang Finance Group"
|
||||
assert payload["finance_approver_name"] == "Wang Finance"
|
||||
|
||||
|
||||
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
|
||||
@@ -147,3 +147,52 @@ def test_system_dashboard_service_aggregates_real_runtime_metrics() -> None:
|
||||
assert dashboard.accuracy_comparison["wrong"][
|
||||
dashboard.accuracy_comparison["categories"].index("异常诊断")
|
||||
] == 1
|
||||
|
||||
|
||||
def test_system_dashboard_counts_online_users_from_active_sessions_across_window() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
UserSessionMetric(
|
||||
session_id="session-online-old-001",
|
||||
username="active.user@example.com",
|
||||
display_name="在线用户",
|
||||
email="active.user@example.com",
|
||||
login_at=now - timedelta(days=2),
|
||||
last_activity_at=now - timedelta(days=2),
|
||||
activity_event_count=3,
|
||||
status="active",
|
||||
),
|
||||
UserSessionMetric(
|
||||
session_id="session-online-old-002",
|
||||
username="active.user@example.com",
|
||||
display_name="在线用户",
|
||||
email="active.user@example.com",
|
||||
login_at=now - timedelta(minutes=15),
|
||||
last_activity_at=now - timedelta(minutes=15),
|
||||
activity_event_count=5,
|
||||
status="active",
|
||||
),
|
||||
UserSessionMetric(
|
||||
session_id="session-closed-outside-window",
|
||||
username="offline.user@example.com",
|
||||
display_name="离线用户",
|
||||
email="offline.user@example.com",
|
||||
login_at=now - timedelta(days=3),
|
||||
logout_at=now - timedelta(days=2, hours=23),
|
||||
duration_ms=60 * 60 * 1000,
|
||||
status="closed",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dashboard = SystemDashboardService(db).build_dashboard(days=1)
|
||||
login_users = dashboard.login_wave["loginUsers"]
|
||||
|
||||
assert dashboard.totals["onlineUsers"] == 1
|
||||
assert max(login_users) == 1
|
||||
assert "00:00" in dashboard.login_wave["labels"]
|
||||
assert "23:00" in dashboard.login_wave["labels"]
|
||||
|
||||
Reference in New Issue
Block a user