feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -155,9 +155,9 @@
"action": "continue"
},
"fail": {
"severity": "high",
"severity": "medium",
"action": "manual_review",
"risk_score": 84
"risk_score": 60
}
},
"metadata": {
@@ -166,8 +166,8 @@
"source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-31T00:10:41.785760+00:00",
"created_by": "system",
"risk_score": 84,
"risk_level": "high",
"risk_score": 60,
"risk_level": "medium",
"rule_title": "项目预算与部门不匹配",
"finance_rule_code": "budget.execution.policy",
"finance_rule_sheet": "预算执行规则",
@@ -179,9 +179,82 @@
"expense_types": [
"all"
],
"budget_required": true
"budget_required": true,
"risk_level_label": "中风险",
"risk_score_model": "risk_score_v3",
"risk_score_detail": {
"score": 60,
"level": "medium",
"level_label": "中风险",
"model": "risk_score_v3",
"weights": {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.1,
"action": 0.1,
"sensitivity": 0.05
},
"components": {
"impact": 78,
"certainty": 58,
"evidence": 62,
"exception": 35,
"action": 35,
"sensitivity": 45
},
"calibration": {
"raw_score": 60,
"rules": []
},
"ai_evidence": {},
"basis": {
"template_key": "keyword_match_v1",
"field_count": 12,
"condition_count": 0,
"expense_category": null,
"expense_category_label": "预算管控",
"requires_attachment": false
}
}
},
"severity": "high",
"risk_score": 84,
"risk_level": "high"
"severity": "medium",
"risk_score": 60,
"risk_level": "medium",
"risk_level_label": "中风险",
"risk_score_detail": {
"score": 60,
"level": "medium",
"level_label": "中风险",
"model": "risk_score_v3",
"weights": {
"impact": 0.35,
"certainty": 0.25,
"evidence": 0.15,
"exception": 0.1,
"action": 0.1,
"sensitivity": 0.05
},
"components": {
"impact": 78,
"certainty": 58,
"evidence": 62,
"exception": 35,
"action": 35,
"sensitivity": 45
},
"calibration": {
"raw_score": 60,
"rules": []
},
"ai_evidence": {},
"basis": {
"template_key": "keyword_match_v1",
"field_count": 12,
"condition_count": 0,
"expense_category": null,
"expense_category_label": "预算管控",
"requires_attachment": false
}
}
}

View File

@@ -45,12 +45,6 @@
"type": "text",
"source": "item"
},
{
"key": "employee.location",
"label": "员工常驻地",
"type": "text",
"source": "employee"
},
{
"key": "attachment.route_cities",
"label": "交通票行程城市",
@@ -83,7 +77,6 @@
"field_keys": [
"claim.location",
"item.item_location",
"employee.location",
"attachment.route_cities",
"attachment.hotel_city",
"claim.reason",
@@ -97,9 +90,7 @@
"attachment.route_cities",
"attachment.hotel_city"
],
"home_city_fields": [
"employee.location"
],
"home_city_fields": [],
"exception_fields": [
"claim.reason",
"item.item_reason"
@@ -113,7 +104,7 @@
"客户拜访",
"项目现场"
],
"condition_summary": "票据城市未覆盖申报目的地,或路线出现常驻地/目的地以外城市且无合理说明。",
"condition_summary": "票据城市未覆盖申报目的地,或路线出现无法由本次票据起终点和申报目的地解释的额外城市且无合理说明。",
"message_template": "差旅票据城市与申报目的地不一致,请补充多地出差、改签或异地住宿说明。"
},
"outcomes": {

View File

@@ -2,7 +2,7 @@
"schema_version": "2.0",
"rule_code": "risk.travel.low.vague_ticket_content",
"name": "差旅票据服务内容笼统低风险",
"description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细。",
"description": "票据商品或服务名称过于笼统,例如仅写服务费、其他、详见清单等,提醒补充明细;已明确识别为火车、机票、酒店、出租车等差旅票据时不按 OCR 全文关键词误判。",
"enabled": true,
"requires_attachment": true,
"risk_dimension": "travel_reimbursement_control",
@@ -41,14 +41,14 @@
},
{
"key": "attachment.ocr_text",
"label": "票据 OCR 全文",
"label": "未识别明确票据类型时的 OCR 兜底文本",
"type": "text",
"source": "attachment"
}
]
},
"params": {
"condition_summary": "票据商品或服务名称过于笼统,无法直接对应差旅事项。",
"condition_summary": "票据未识别为明确的酒店、交通等差旅票据,且商品或服务名称过于笼统,无法直接对应差旅事项。",
"message_template": "差旅票据服务内容较笼统,请补充明细清单或业务说明。"
},
"outcomes": {

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_SRC = ROOT / "src"
if str(APP_SRC) not in sys.path:
sys.path.insert(0, str(APP_SRC))
from app.services.ontology_field_registry import ( # noqa: E402
CANONICAL_ONTOLOGY_FIELDS,
ONTOLOGY_CONTEXT_METADATA_FIELDS,
ONTOLOGY_FIELD_ALIASES,
REGISTERED_ONTOLOGY_CONTEXT_FIELDS,
)
SCAN_ROOTS = (ROOT / "src" / "app", ROOT.parent / "web" / "src")
SKIP_PARTS = {"__pycache__", ".pytest_cache", ".ruff_cache", "node_modules", "dist"}
FIELD_PATTERNS = (
re.compile(r"""context_json\.get\(["']([^"']+)["']"""),
re.compile(r"""review_form_values\.get\(["']([^"']+)["']"""),
re.compile(r"""form_values\.get\(["']([^"']+)["']"""),
re.compile(r"""review_values\.get\(["']([^"']+)["']"""),
)
@dataclass(frozen=True)
class Finding:
file: Path
line_no: int
field: str
kind: str
source: str
def iter_source_files() -> list[Path]:
files: list[Path] = []
for root in SCAN_ROOTS:
if not root.exists():
continue
for path in root.rglob("*"):
if any(part in SKIP_PARTS for part in path.parts):
continue
if path.suffix not in {".py", ".js", ".vue", ".mjs", ".ts"}:
continue
files.append(path)
return sorted(files)
def collect_findings() -> tuple[list[Finding], list[Finding]]:
alias_fields = {alias for aliases in ONTOLOGY_FIELD_ALIASES.values() for alias in aliases}
unknown: list[Finding] = []
alias_reads: list[Finding] = []
for path in iter_source_files():
if path.name == "ontology_field_registry.py":
continue
text = path.read_text(encoding="utf-8", errors="ignore")
for line_no, line in enumerate(text.splitlines(), start=1):
for pattern in FIELD_PATTERNS:
for match in pattern.finditer(line):
field = match.group(1)
source = match.group(0)
if field in alias_fields and field not in ONTOLOGY_CONTEXT_METADATA_FIELDS:
alias_reads.append(Finding(path, line_no, field, "alias_read", source))
if field not in REGISTERED_ONTOLOGY_CONTEXT_FIELDS:
unknown.append(Finding(path, line_no, field, "unknown", source))
return unknown, alias_reads
def print_section(title: str, findings: list[Finding]) -> None:
print(f"\n{title}: {len(findings)}")
for item in findings[:200]:
relative = item.file.relative_to(ROOT.parent)
print(f"- {relative}:{item.line_no} field={item.field} source={item.source}")
if len(findings) > 200:
print(f"- ... {len(findings) - 200} more")
def main() -> int:
parser = argparse.ArgumentParser(description="Audit ontology context field usage.")
parser.add_argument("--strict", action="store_true", help="Exit non-zero when findings exist.")
args = parser.parse_args()
unknown, alias_reads = collect_findings()
print(f"canonical_fields: {len(CANONICAL_ONTOLOGY_FIELDS)}")
print(f"context_metadata_fields: {len(ONTOLOGY_CONTEXT_METADATA_FIELDS)}")
print_section("unknown_context_fields", unknown)
print_section("direct_alias_reads", alias_reads)
if args.strict and (unknown or alias_reads):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -86,6 +86,7 @@ class ExpenseClaimItem(Base):
item_type: Mapped[str] = mapped_column(String(50))
item_reason: Mapped[str] = mapped_column(Text())
item_location: Mapped[str] = mapped_column(String(100))
item_note: Mapped[str] = mapped_column(Text(), default="")
item_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -12,6 +12,19 @@ class ReceiptFolderFieldRead(BaseModel):
value: str = ""
class ReceiptFolderFieldChangeRead(BaseModel):
key: str = ""
label: str = ""
before: str = ""
after: str = ""
class ReceiptFolderEditLogRead(BaseModel):
operated_at: datetime | None = None
operator: str = ""
changes: list[ReceiptFolderFieldChangeRead] = Field(default_factory=list)
class ReceiptFolderItemRead(BaseModel):
id: str
file_name: str
@@ -48,6 +61,7 @@ class ReceiptFolderDetailRead(ReceiptFolderItemRead):
classification_confidence: float = 0.0
classification_evidence: list[str] = Field(default_factory=list)
fields: list[ReceiptFolderFieldRead] = Field(default_factory=list)
edit_logs: list[ReceiptFolderEditLogRead] = Field(default_factory=list)
raw_meta: dict[str, Any] = Field(default_factory=dict)

View File

@@ -39,6 +39,7 @@ class ExpenseClaimItemRead(BaseModel):
item_type: str
item_reason: str
item_location: str
item_note: str = ""
item_amount: Decimal
invoice_id: str | None
is_system_generated: bool = False
@@ -101,6 +102,7 @@ class ExpenseClaimItemUpdate(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
invoice_id: str | None = None
@@ -110,6 +112,7 @@ class ExpenseClaimItemCreate(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
invoice_id: str | None = None
@@ -203,6 +206,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
claim_risk_flags: list[Any] = Field(default_factory=list)

View File

@@ -216,7 +216,7 @@ class AgentAssetRiskRuleSimulationMixin:
if field_key == "item.item_location":
return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点"))
if field_key == "employee.location":
return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地", "出发地"))
return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地"))
if "city" in field_key or "location" in field_key:
if any(
token in key_text
@@ -387,7 +387,6 @@ class AgentAssetRiskRuleSimulationMixin:
for group_name in (
"attachment_city_fields",
"reference_city_fields",
"home_city_fields",
"exception_fields",
):
for key in self._read_string_list(params.get(group_name)):

View File

@@ -622,7 +622,7 @@ class AgentAssetRiskRuleTestingMixin:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if template_key == "field_compare_v1":
if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}:
values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京", "employee.location": "北京"})
values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京"})
return values
condition = next(
(item for item in params.get("conditions", []) if isinstance(item, dict)),

View File

@@ -39,7 +39,8 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.pagination import PageResult
from app.services.pagination import PageResult, normalize_page_params
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
logger = get_logger("app.services.agent_assets")
@@ -77,6 +78,7 @@ class AgentAssetService(
assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword
)
assets = self._filter_excluded_risk_assets(assets)
version_stats = self._collect_version_stats(assets)
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
@@ -93,17 +95,24 @@ class AgentAssetService(
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
result = self.repository.list_page(
assets = self.repository.list(
asset_type=asset_type,
status=status,
domain=domain,
keyword=keyword,
page=page,
page_size=page_size,
)
version_stats = self._collect_version_stats(result.items)
return result.map(
lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id))
assets = self._filter_excluded_risk_assets(assets)
page_params = normalize_page_params(page, page_size)
paged_assets = assets[page_params.offset : page_params.offset + page_params.page_size]
version_stats = self._collect_version_stats(paged_assets)
return PageResult(
items=[
self._serialize_list_item(asset, version_stats.get(asset.id))
for asset in paged_assets
],
total=len(assets),
page=page_params.page,
page_size=page_params.page_size,
)
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
@@ -151,6 +160,26 @@ class AgentAssetService(
else None,
)
@staticmethod
def _filter_excluded_risk_assets(assets: list[AgentAsset]) -> list[AgentAsset]:
return [asset for asset in assets if not AgentAssetService._is_excluded_budget_risk_asset(asset)]
@staticmethod
def _is_excluded_budget_risk_asset(asset: AgentAsset) -> bool:
if asset.asset_type != AgentAssetType.RULE.value:
return False
config_json = asset.config_json if isinstance(asset.config_json, dict) else {}
if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk":
return False
manifest_like = {
**config_json,
"rule_code": str(asset.code or "").strip(),
"name": str(asset.name or "").strip(),
"description": str(asset.description or "").strip(),
"metadata": config_json,
}
return is_budget_risk_manifest(manifest_like)
def create_asset(
self,
payload: AgentAssetCreate,

View File

@@ -124,6 +124,12 @@ class AgentFoundationService(
"ON expense_claims (hermes_risk_flag)"
)
)
if "expense_claim_items" in inspector.get_table_names():
item_column_names = {column["name"] for column in inspector.get_columns("expense_claim_items")}
if "item_note" not in item_column_names:
self.db.execute(
text("ALTER TABLE expense_claim_items ADD COLUMN item_note TEXT DEFAULT '' NOT NULL")
)
self.db.flush()
def _sync_demo_financial_records(self) -> None:

View File

@@ -20,6 +20,7 @@ from app.services.agent_asset_spreadsheet import (
from app.services.agent_foundation_constants import (
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
logger = get_logger("app.services.agent_foundation")
@@ -63,6 +64,10 @@ class AgentFoundationRiskRuleMixin:
continue
if is_budget_risk_manifest(payload):
continue
manifests.append((file_name, payload))
return manifests

View File

@@ -21,8 +21,8 @@ APPLICATION_EXPENSE_TYPES = {
"preapproval",
}
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501
RECENT_VISIBLE_CLAIM_END = 817
RECENT_VISIBLE_CLAIM_START = 401
RECENT_VISIBLE_CLAIM_END = 424
def is_admin_identity(*values: Any) -> bool:
@@ -99,7 +99,8 @@ def simulation_claim_day(
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
distribution_months = complete_distribution_months(months, period_end)
month = distribution_months[(employee_index + local_index * 2) % len(distribution_months)]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
@@ -108,16 +109,26 @@ def simulation_claim_day(
def simulation_claim_count(employee: Any, index: int) -> int:
base = 7 + (index % 5)
base = 3 + (1 if index % 3 == 0 else 0)
department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "")
grade = str(getattr(employee, "grade", "") or "")
if department_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
base += 1
elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
base += 1
if grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
base += 1
return max(3, min(base, 6))
def complete_distribution_months(months: list[date], period_end: date) -> list[date]:
complete_months: list[date] = []
for month in months:
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month and period_end.day < 10:
continue
complete_months.append(month)
return complete_months or months
def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]:

View File

@@ -0,0 +1,373 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.demo_company_simulation_catalog import (
BUDGETED_STATUSES,
SIM_BUDGET_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
SIM_RISK_PREFIX,
SIM_TRANSACTION_PREFIX,
build_simulation_reimbursement_no,
target_budget_usage,
)
@dataclass(frozen=True, slots=True)
class SimulationRebalanceSummary:
mode: str
claims: int
main_period_claims: int
recent_claims: int
period_start: str
period_end: str
max_daily_count: int
budget_transactions: int
budget_reservations: int
risk_observations: int
allocation_missing_count: int
def to_dict(self) -> dict[str, object]:
return asdict(self)
class HalfYearExpenseSimulationRebalancer:
"""Rebalance existing simulation rows without deleting business records."""
def __init__(
self,
db: Session,
*,
start_date: date = date(2026, 1, 1),
end_date: date = date(2026, 6, 2),
recent_sample_days: int = 2,
) -> None:
self.db = db
self.start_date = start_date
self.end_date = end_date
self.main_period_end = date(end_date.year, end_date.month, 1) - timedelta(days=1)
self.recent_sample_days = max(1, recent_sample_days)
def preview(self) -> SimulationRebalanceSummary:
return self._run(apply=False)
def apply(self) -> SimulationRebalanceSummary:
return self._run(apply=True)
def _run(self, *, apply: bool) -> SimulationRebalanceSummary:
claims = self._simulation_claims()
plans = self._claim_plans(claims)
allocation_map = self._allocation_map()
allocation_missing_count = self._count_missing_allocations(plans, allocation_map)
day_counts: dict[date, int] = {}
for _claim, plan in plans:
day_counts[plan["day"]] = day_counts.get(plan["day"], 0) + 1
if apply and plans:
self._apply_claim_plans(plans, allocation_map)
self._rebalance_allocation_amounts()
self.db.flush()
recent_count = sum(1 for _claim, plan in plans if plan["day"] >= date(2026, 6, 1))
return SimulationRebalanceSummary(
mode="apply" if apply else "dry-run",
claims=len(claims),
main_period_claims=len(claims) - recent_count,
recent_claims=recent_count,
period_start=self.start_date.isoformat(),
period_end=self.end_date.isoformat(),
max_daily_count=max(day_counts.values()) if day_counts else 0,
budget_transactions=self._sim_transaction_count(),
budget_reservations=self._sim_reservation_count(),
risk_observations=self._sim_risk_count(),
allocation_missing_count=allocation_missing_count,
)
def _simulation_claims(self) -> list[ExpenseClaim]:
return list(
self.db.scalars(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.claim_no.asc(), ExpenseClaim.id.asc())
).all()
)
def _claim_plans(self, claims: list[ExpenseClaim]) -> list[tuple[ExpenseClaim, dict[str, object]]]:
recent_count = self._recent_count(len(claims))
main_count = max(len(claims) - recent_count, 0)
main_days = self._date_range(self.start_date, self.main_period_end)
recent_days = self._date_range(date(2026, 6, 1), self.end_date)
plans: list[tuple[ExpenseClaim, dict[str, object]]] = []
for index, claim in enumerate(claims):
if index < main_count:
day = self._spread_day(index, main_count, main_days)
else:
recent_index = index - main_count
day = recent_days[recent_index % len(recent_days)]
occurred_at = datetime.combine(day, time(hour=8 + (index % 9)), tzinfo=UTC)
submitted_at = None
if self._status(claim) != "draft":
submitted_at = datetime.combine(day, time(hour=9 + (index % 7)), tzinfo=UTC)
updated_at = self._updated_at(claim, occurred_at, submitted_at, index)
final_claim_no = build_simulation_reimbursement_no(occurred_at, index + 1)
period_key = f"{occurred_at.year}Q{((occurred_at.month - 1) // 3) + 1}"
subject_code = "meal" if str(claim.expense_type or "") == "entertainment" else str(claim.expense_type or "")
plans.append(
(
claim,
{
"sequence": index + 1,
"day": day,
"occurred_at": occurred_at,
"submitted_at": submitted_at,
"updated_at": updated_at,
"claim_no": final_claim_no,
"period_key": period_key,
"subject_code": subject_code,
},
)
)
return plans
def _apply_claim_plans(
self,
plans: list[tuple[ExpenseClaim, dict[str, object]]],
allocation_map: dict[tuple[str | None, str, str], str],
) -> None:
claim_ids = [claim.id for claim, _plan in plans]
transactions_by_claim = self._transactions_by_claim_id(claim_ids)
reservations_by_claim = self._reservations_by_claim_id(claim_ids)
observations_by_claim = self._observations_by_claim_id(claim_ids)
for claim, plan in plans:
claim.claim_no = f"SIM-TEMP-{claim.id}"
self.db.flush()
for claim, plan in plans:
claim_no = str(plan["claim_no"])
occurred_at = plan["occurred_at"]
submitted_at = plan["submitted_at"]
updated_at = plan["updated_at"]
allocation_id = allocation_map.get(
(
claim.department_id,
str(plan["period_key"]),
str(plan["subject_code"]),
)
)
claim.claim_no = claim_no
claim.occurred_at = occurred_at
claim.submitted_at = submitted_at
claim.created_at = occurred_at
claim.updated_at = updated_at
claim.reason = self._normalized_reason(claim.reason, occurred_at.date())
self.db.execute(
text(
"""
update expense_claim_items
set item_date = :item_date, updated_at = :updated_at
where claim_id = :claim_id
"""
),
{
"item_date": occurred_at.date(),
"updated_at": updated_at,
"claim_id": claim.id,
},
)
for transaction in transactions_by_claim.get(claim.id, []):
transaction.source_no = claim_no
transaction.created_at = submitted_at or occurred_at
if allocation_id:
transaction.allocation_id = allocation_id
for reservation in reservations_by_claim.get(claim.id, []):
reservation.source_no = claim_no
reservation.created_at = submitted_at or occurred_at
reservation.updated_at = updated_at
if allocation_id:
reservation.allocation_id = allocation_id
for observation in observations_by_claim.get(claim.id, []):
observation.subject_key = claim_no
observation.subject_label = claim_no
observation.claim_no = claim_no
observation.created_at = submitted_at or occurred_at
observation.updated_at = updated_at
def _allocation_map(self) -> dict[tuple[str | None, str, str], str]:
rows = self.db.scalars(
select(BudgetAllocation).where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
).all()
return {
(row.department_id, row.period_key, row.subject_code): row.id
for row in rows
}
def _count_missing_allocations(
self,
plans: list[tuple[ExpenseClaim, dict[str, object]]],
allocation_map: dict[tuple[str | None, str, str], str],
) -> int:
missing = {
(claim.department_id, str(plan["period_key"]), str(plan["subject_code"]))
for claim, plan in plans
if self._status(claim) in BUDGETED_STATUSES
and (claim.department_id, str(plan["period_key"]), str(plan["subject_code"])) not in allocation_map
}
return len(missing)
def _rebalance_allocation_amounts(self) -> None:
allocations = list(
self.db.scalars(
select(BudgetAllocation)
.where(BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%"))
.order_by(BudgetAllocation.period_key.asc(), BudgetAllocation.subject_code.asc())
).all()
)
transactions = list(
self.db.scalars(
select(BudgetTransaction).where(
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
)
).all()
)
used_by_allocation: dict[str, Decimal] = {}
for transaction in transactions:
used_by_allocation[transaction.allocation_id] = (
used_by_allocation.get(transaction.allocation_id, Decimal("0.00"))
+ Decimal(transaction.amount or 0)
)
for index, allocation in enumerate(allocations):
used = used_by_allocation.get(allocation.id, Decimal("0.00"))
usage = target_budget_usage(allocation.period_key, allocation.subject_code, index)
allocation.original_amount = max(
(used / usage).quantize(Decimal("0.01")) if usage > 0 else used,
Decimal("3000.00"),
)
allocation.updated_by = "simulation_rebalance"
allocation.updated_at = datetime.now(UTC)
def _transactions_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetTransaction]]:
rows = self.db.scalars(
select(BudgetTransaction)
.where(BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%"))
.where(BudgetTransaction.source_id.in_(claim_ids))
).all()
return self._group_by_source_id(rows)
def _reservations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetReservation]]:
rows = self.db.scalars(
select(BudgetReservation)
.where(BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%"))
.where(BudgetReservation.source_id.in_(claim_ids))
).all()
return self._group_by_source_id(rows)
def _observations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[RiskObservation]]:
rows = self.db.scalars(
select(RiskObservation)
.where(RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%"))
.where(RiskObservation.claim_id.in_(claim_ids))
).all()
grouped: dict[str, list[RiskObservation]] = {}
for row in rows:
if row.claim_id:
grouped.setdefault(row.claim_id, []).append(row)
return grouped
@staticmethod
def _group_by_source_id(rows: object) -> dict[str, list[object]]:
grouped: dict[str, list[object]] = {}
for row in rows:
grouped.setdefault(row.source_id, []).append(row)
return grouped
def _recent_count(self, total: int) -> int:
if total <= 0:
return 0
return min(24, max(12, total // 50))
@staticmethod
def _date_range(start: date, end: date) -> list[date]:
days = max((end - start).days, 0)
return [start + timedelta(days=index) for index in range(days + 1)]
@staticmethod
def _spread_day(index: int, count: int, days: list[date]) -> date:
if not days:
raise ValueError("days cannot be empty")
if count <= 1:
return days[0]
day_index = round(index * (len(days) - 1) / (count - 1))
jitter = ((index * 17) % 5) - 2
return days[max(0, min(len(days) - 1, day_index + jitter))]
@staticmethod
def _updated_at(
claim: ExpenseClaim,
occurred_at: datetime,
submitted_at: datetime | None,
index: int,
) -> datetime:
base = submitted_at or occurred_at
status = HalfYearExpenseSimulationRebalancer._status(claim)
if status == "paid":
return base + timedelta(days=2 + (index % 3), hours=index % 5)
if status in {"approved", "pending_payment"}:
return base + timedelta(days=1 + (index % 2), hours=index % 4)
if status in {"returned", "rejected"}:
return base + timedelta(hours=6 + (index % 8))
return base + timedelta(hours=2 + (index % 4))
@staticmethod
def _normalized_reason(reason: str, day: date) -> str:
text = str(reason or "").strip()
for month in range(1, 7):
text = text.replace(f"{month}", f"{day.month}")
return text
@staticmethod
def _status(claim: ExpenseClaim) -> str:
return str(claim.status or "").strip().lower()
def _sim_transaction_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(BudgetTransaction).where(
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
)
)
or 0
)
def _sim_reservation_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(BudgetReservation).where(
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
)
)
or 0
)
def _sim_risk_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(RiskObservation).where(
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
)
)
or 0
)

View File

@@ -275,6 +275,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"item_type": item.item_type,
"item_reason": item.item_reason,
"item_location": item.item_location,
"item_note": item.item_note,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"claim_risk_flags": list(claim.risk_flags_json or []),

View File

@@ -107,6 +107,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService
@@ -344,10 +345,10 @@ class ExpenseClaimDocumentItemBuilderMixin:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
review_type = str(
review_form_values.get("expense_type")
or review_form_values.get("scene_label")
or review_form_values.get("reason_value")
or review_form_values.get("reason")
or ""
)
if any(keyword in review_type for keyword in ("差旅", "出差")):
@@ -377,12 +378,8 @@ class ExpenseClaimDocumentItemBuilderMixin:
else:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
time_text = str(
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
review_form_values = normalize_ontology_form_values(review_form_values)
time_text = str(review_form_values.get("time_range") or "").strip()
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
if matched_dates:
start_date = self._parse_iso_date_or_default(matched_dates[0], start_date)
@@ -400,15 +397,13 @@ class ExpenseClaimDocumentItemBuilderMixin:
review_form_values = context_json.get("review_form_values")
text_parts: list[str] = []
if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
text_parts.extend(
str(review_form_values.get(key) or "")
for key in (
"reason",
"business_reason",
"reason_value",
"scene_label",
"time_range",
"business_time",
"expense_type",
)
)
text_parts.extend(

View File

@@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService
@@ -204,11 +205,8 @@ class ExpenseClaimOntologyResolverMixin:
def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
compact = str(
review_form_values.get("expense_type")
or review_form_values.get("reimbursement_type")
or ""
).replace(" ", "")
review_form_values = normalize_ontology_form_values(review_form_values)
compact = str(review_form_values.get("expense_type") or "").replace(" ", "")
if compact:
return resolve_expense_type_code_from_text(compact)
return None
@@ -238,10 +236,10 @@ class ExpenseClaimOntologyResolverMixin:
) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in ("reason", "business_reason"):
value = str(review_form_values.get(key) or "").strip()
if value:
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("reason") or "").strip()
if value:
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
explicit_text = context_json.get("user_input_text")
if isinstance(explicit_text, str):
@@ -281,10 +279,10 @@ class ExpenseClaimOntologyResolverMixin:
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return value
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("location") or "").strip()
if value:
return value
request_context = context_json.get("request_context")
if (
@@ -314,16 +312,9 @@ class ExpenseClaimOntologyResolverMixin:
) -> datetime | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in (
"occurred_date",
"time_range",
"business_time",
"application_business_time",
"application_time",
):
value = str(review_form_values.get(key) or "").strip()
if not value:
continue
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("time_range") or "").strip()
if value:
try:
parsed = date.fromisoformat(value)
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)

View File

@@ -16,6 +16,7 @@ from app.services.expense_rule_runtime import (
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
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
@@ -23,6 +24,44 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
_CLEAR_TRAVEL_DOCUMENT_TYPES = {
"flight_itinerary",
"train_ticket",
"ship_ticket",
"hotel_invoice",
"taxi_receipt",
"parking_toll_receipt",
}
_CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"}
_GOODS_DESCRIPTION_FIELD_KEYS = {
"goodsname",
"servicename",
"itemname",
"project",
"productname",
"description",
"content",
"expensecontent",
"feeitem",
}
_GOODS_DESCRIPTION_LABEL_TOKENS = (
"商品",
"服务",
"货物",
"项目",
"品名",
"名称",
"费用内容",
"消费内容",
)
_VAGUE_KEYWORD_NEGATION_MARKERS = (
"不含",
"不包含",
"不包括",
"未包含",
"不涉及",
"不属于",
)
def evaluate_platform_risk_rules(
self,
@@ -127,6 +166,8 @@ class ExpenseClaimPlatformRiskMixin:
manifest_code = str(payload.get("rule_code") or rule_code).strip()
if not manifest_code or (code_filter and manifest_code not in code_filter):
continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
@@ -162,6 +203,8 @@ class ExpenseClaimPlatformRiskMixin:
continue
if code_filter and rule_code not in missing_codes:
continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
@@ -364,7 +407,7 @@ class ExpenseClaimPlatformRiskMixin:
fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。",
)
if evaluator == "vague_goods_description":
return self._evaluate_text_keyword_risk(
return self._evaluate_vague_goods_description_risk(
manifest,
contexts=contexts,
keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"],
@@ -663,6 +706,107 @@ class ExpenseClaimPlatformRiskMixin:
evidence={"matched_keywords": matched},
)
def _evaluate_vague_goods_description_risk(
self,
manifest: dict[str, Any],
*,
contexts: list[dict[str, Any]],
keywords: list[str],
fallback_message: str,
) -> dict[str, Any] | None:
matched_keywords: list[str] = []
matched_fields: list[dict[str, str]] = []
for context in contexts:
document_info = context.get("document_info") or {}
if self._is_clear_travel_document(document_info):
continue
field_values = self._collect_goods_description_field_values(document_info)
if field_values:
for value in field_values:
hits = self._collect_non_negated_keyword_hits(value, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": value[:80],
}
)
continue
fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}"
hits = self._collect_non_negated_keyword_hits(fallback_text, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": "OCR全文兜底",
}
)
if not matched_keywords:
return None
return self._build_platform_risk_flag(
manifest,
message=fallback_message,
evidence={
"matched_keywords": matched_keywords,
"matched_fields": matched_fields[:5],
},
)
@classmethod
def _is_clear_travel_document(cls, document_info: dict[str, Any]) -> bool:
document_type = str(document_info.get("document_type") or "").strip().lower()
scene_code = str(document_info.get("scene_code") or "").strip().lower()
return (
document_type in cls._CLEAR_TRAVEL_DOCUMENT_TYPES
or scene_code in cls._CLEAR_TRAVEL_SCENE_CODES
)
@classmethod
def _collect_goods_description_field_values(cls, document_info: dict[str, Any]) -> list[str]:
values: list[str] = []
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in cls._GOODS_DESCRIPTION_FIELD_KEYS or any(
token in label for token in cls._GOODS_DESCRIPTION_LABEL_TOKENS
):
values.append(value)
return values
@classmethod
def _collect_non_negated_keyword_hits(cls, text: str, keywords: list[str]) -> list[str]:
normalized = str(text or "")
if not normalized:
return []
hits: list[str] = []
for keyword in keywords:
if not keyword:
continue
for match in re.finditer(re.escape(keyword), normalized):
window = normalized[max(0, match.start() - 12): match.end() + 12]
if any(marker in window for marker in cls._VAGUE_KEYWORD_NEGATION_MARKERS):
continue
hits.append(keyword)
break
return hits
def _evaluate_multi_city_reason_required_risk(
self,
manifest: dict[str, Any],

View File

@@ -36,6 +36,7 @@ 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.document_numbering import is_application_claim_no
from app.services.budget_types import BudgetControlError
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
@@ -57,6 +58,7 @@ from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyRe
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService
from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS,
MAX_DRAFT_CLAIMS_PER_USER,
@@ -320,6 +322,8 @@ class ExpenseClaimService(
item.item_location = (
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
)
if payload.item_note is not None:
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"):
@@ -376,6 +380,7 @@ class ExpenseClaimService(
or "other",
item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "",
item_location=self._normalize_optional_text(payload.item_location, fallback="") or "",
item_note=self._normalize_optional_text(payload.item_note, allow_empty=True) or "",
item_amount=item_amount,
invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True),
)
@@ -462,11 +467,16 @@ class ExpenseClaimService(
if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields)
budget_flags = self._reserve_budget_for_submission(
claim,
current_user,
is_application_claim=is_application_claim,
)
try:
budget_flags = self._reserve_budget_for_submission(
claim,
current_user,
is_application_claim=is_application_claim,
)
except BudgetControlError as exc:
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)
@@ -576,6 +586,7 @@ class ExpenseClaimService(
self._release_budget_for_delete(claim, current_user)
self._delete_claim_analysis_records(resource_id)
self._attachment_storage.delete_claim_files(claim)
ReceiptFolderService().delete_receipts_for_claim(resource_id)
self.db.delete(claim)
self.db.commit()
@@ -747,11 +758,6 @@ class ExpenseClaimService(

View File

@@ -49,8 +49,18 @@ class FinanceDashboardService(BudgetSupportMixin):
now=now,
)
previous_start = start - (end - start)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
trend_range,
now,
fallback_start=start,
fallback_end=end,
)
ranking_start, ranking_end = self._resolve_ranking_scope(
department_range,
now,
fallback_start=start,
fallback_end=end,
)
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -127,10 +137,31 @@ class FinanceDashboardService(BudgetSupportMixin):
self,
trend_range: str,
now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime, list[str]]:
days = self._days_from_label(trend_range, default=12)
end_day = now.date()
start_day = end_day - timedelta(days=days - 1)
today = now.date()
key = str(trend_range or "").strip()
if key in {"custom", "自定义"} and fallback_start and fallback_end:
start_day = fallback_start.date()
end_day = (fallback_end - timedelta(days=1)).date()
elif key == "今日":
start_day = today
end_day = today
elif key == "本周":
start_day = today - timedelta(days=today.weekday())
end_day = today
elif key == "本月":
start_day = today.replace(day=1)
end_day = today
else:
days = self._days_from_label(trend_range, default=12)
end_day = today
start_day = end_day - timedelta(days=days - 1)
if start_day > end_day:
start_day, end_day = end_day, start_day
days = max(1, (end_day - start_day).days + 1)
labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)]
return self._day_start(start_day), self._day_after(end_day), labels
@@ -138,9 +169,32 @@ class FinanceDashboardService(BudgetSupportMixin):
self,
department_range: str,
now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime]:
today = now.date()
key = str(department_range or "").strip()
if key in {"custom", "自定义"} and fallback_start and fallback_end:
return fallback_start, fallback_end
if key == "今日":
return self._day_start(today), self._day_after(today)
if key == "本周":
start_day = today - timedelta(days=today.weekday())
return self._day_start(start_day), self._day_after(today)
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
quarter_month = ((today.month - 1) // 3) * 3 + 1
return self._day_start(today.replace(month=quarter_month, day=1)), self._day_after(today)
if key == "本年":
return self._day_start(today.replace(month=1, day=1)), self._day_after(today)
if key == "本月":
return self._day_start(today.replace(day=1)), self._day_after(today)
if re.search(r"\d+", key):
days = self._days_from_label(key, default=10)
start_day = today - timedelta(days=days - 1)
return self._day_start(start_day), self._day_after(today)
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
@@ -227,6 +281,8 @@ class FinanceDashboardService(BudgetSupportMixin):
claim_count = [0 for _ in labels]
claim_amount = [Decimal("0.00") for _ in labels]
success_count = [0 for _ in labels]
category_amounts: dict[str, list[Decimal]] = {}
category_totals: dict[str, Decimal] = defaultdict(Decimal)
hours: list[list[Decimal]] = [[] for _ in labels]
index = {label: idx for idx, label in enumerate(labels)}
@@ -237,8 +293,12 @@ class FinanceDashboardService(BudgetSupportMixin):
if label not in index:
continue
bucket = index[label]
amount = self._claim_amount(claim)
category = self._expense_type_label(claim.expense_type)
claim_count[bucket] += 1
claim_amount[bucket] += self._claim_amount(claim)
claim_amount[bucket] += amount
category_amounts.setdefault(category, [Decimal("0.00") for _ in labels])[bucket] += amount
category_totals[category] += amount
if self._status(claim) in SUCCESS_STATUSES:
success_count[bucket] += 1
if claim.submitted_at:
@@ -248,6 +308,17 @@ class FinanceDashboardService(BudgetSupportMixin):
"labels": labels,
"claimCount": claim_count,
"claimAmount": [self._decimal_number(value) for value in claim_amount],
"categoryAmountSeries": [
{
"name": name,
"color": CHART_COLORS[index % len(CHART_COLORS)],
"data": [self._decimal_number(value) for value in category_amounts[name]],
"total": self._decimal_number(category_totals[name]),
}
for index, name in enumerate(
sorted(category_amounts, key=lambda item: category_totals[item], reverse=True)[:6]
)
],
"successCount": success_count,
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
"applications": claim_count,

View File

@@ -29,6 +29,7 @@ from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService
from app.services.ontology_detection import OntologyDetectionMixin
from app.services.ontology_extraction import OntologyExtractionMixin
from app.services.ontology_field_registry import normalize_ontology_context_json
from app.services.ontology_rules import (
CONTEXTUAL_SCENARIOS,
EXPENSE_REVIEW_ACTIONS,
@@ -103,7 +104,8 @@ class SemanticOntologyService(
raise ValueError("query 不能为空。")
AgentFoundationService(self.db).ensure_foundation_ready()
context_json = payload.context_json or {}
context_json = normalize_ontology_context_json(payload.context_json or {})
payload = payload.model_copy(update={"context_json": context_json})
reference = self._load_reference_catalog()
compact_query = self._compact(query)
entities = self._extract_entities(query, compact_query, reference, context_json=context_json)

View File

@@ -14,6 +14,7 @@ from app.schemas.ontology import (
OntologyTimeRange,
)
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ontology_budget import BudgetOntologyMixin
from app.services.ontology_rules import (
AMOUNT_PATTERN,
@@ -82,9 +83,7 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
)
if application_mode:
form_values = context_json.get("review_form_values")
if not isinstance(form_values, dict):
form_values = {}
form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
expense_type_codes = {
str(item.normalized_value or item.value or "").strip()
for item in entities
@@ -95,17 +94,10 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
missing_slots.append("expense_type")
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
missing_slots.append("amount")
if not time_range.start_date and not (
str(form_values.get("time_range") or form_values.get("business_time") or "").strip()
):
if not time_range.start_date and not str(form_values.get("time_range") or "").strip():
missing_slots.append("time_range")
reason_value = str(
form_values.get("reason")
or form_values.get("business_reason")
or form_values.get("reason_value")
or ""
).strip()
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
reason_text = str(form_values.get("reason") or "").strip()
if not reason_text and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
missing_slots.append("reason")
if (
attachment_count <= 0
@@ -171,12 +163,33 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
) -> list[OntologyEntity]:
entities: dict[tuple[str, str], OntologyEntity] = {}
context_json = context_json or {}
form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
def upsert(entity: OntologyEntity) -> None:
key = (entity.type, entity.normalized_value)
if key not in entities:
entities[key] = entity
context_entity_specs = (
("expense_type", "expense_type", "filter", 0.86),
("location", "location", "filter", 0.82),
("reason", "reason", "target", 0.82),
("amount", "amount", "target", 0.82),
("transport_mode", "transport_mode", "filter", 0.9),
)
for field_key, entity_type, role, confidence in context_entity_specs:
value = str(form_values.get(field_key) or "").strip()
if value:
upsert(
self._make_entity(
entity_type,
value,
value,
role=role,
confidence=confidence,
)
)
if (
self._is_expense_application_context_value(context_json)
or self._has_expense_application_signal(compact_query)

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import Any
ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"expense_type": ("reimbursement_type", "scene_label", "expenseType"),
"time_range": (
"business_time",
"businessTime",
"occurred_date",
"occurredDate",
"application_business_time",
"applicationBusinessTime",
"application_time",
"applicationTime",
),
"location": (
"business_location",
"businessLocation",
"application_location",
"applicationLocation",
),
"reason": (
"reason_value",
"reasonValue",
"business_reason",
"businessReason",
"application_reason",
"applicationReason",
),
"amount": (
"application_amount",
"applicationAmount",
"application_amount_label",
"applicationAmountLabel",
),
"transport_mode": (
"transport_type",
"transportType",
"transportMode",
"application_transport_mode",
"applicationTransportMode",
),
"attachments": ("attachment_names", "attachmentNames"),
"customer_name": ("customerName",),
"merchant_name": ("merchantName",),
"cost_center": ("costCenter",),
"department_name": ("department", "departmentName", "deptName"),
"employee_grade": ("grade", "user_grade", "employeeGrade", "position_grade"),
"employee_name": ("name", "user_name", "applicant", "claimant_name", "reporter_name"),
"employee_no": ("employeeNo",),
"employee_position": ("position", "employeePosition"),
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
}
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
{
"participants",
"department",
"budget_period",
"budget_subject",
"budget_amount",
"cost_center",
"warning_threshold",
"control_action",
"employee_location",
"employee_risk_profile",
"finance_owner_name",
"document_id",
"application_claim_id",
"application_claim_no",
"application_days",
"application_date",
"application_lodging_daily_cap",
"application_subsidy_daily_cap",
"application_transport_policy",
"application_policy_estimate",
"application_rule_name",
"application_rule_version",
}
)
ONTOLOGY_CONTEXT_METADATA_FIELDS = frozenset(
{
"_claim_no_retry_count",
"actor",
"application_edit_claim_id",
"application_edit_mode",
"applicationEditClaimId",
"applicationEditMode",
"application_fields",
"application_preview",
"application_stage",
"attachment_count",
"attachment_names",
"business_time_context",
"budget_details",
"budget_header",
"client_now_iso",
"client_timezone_offset_minutes",
"conversation_history",
"conversation_id",
"conversation_intent",
"conversation_scenario",
"conversation_state",
"document_type",
"draft_claim_id",
"dry_run_email",
"email",
"entry_source",
"expense_scene_selection",
"force",
"is_admin",
"ocr_documents",
"ocr_summary",
"report_type",
"request_context",
"requested_by_name",
"requested_by_username",
"review_action",
"review_document_form_values",
"review_form_values",
"role_codes",
"role",
"send_email",
"session_type",
"simulate_orchestrator_exception",
"simulate_tool_failure",
"time_range_raw",
"user_id",
"user_input_text",
"username",
}
)
REGISTERED_ONTOLOGY_CONTEXT_FIELDS = (
CANONICAL_ONTOLOGY_FIELDS
| ONTOLOGY_CONTEXT_METADATA_FIELDS
| frozenset(alias for aliases in ONTOLOGY_FIELD_ALIASES.values() for alias in aliases)
)
def normalize_ontology_form_values(values: Any) -> dict[str, str]:
if not isinstance(values, dict):
return {}
normalized: dict[str, str] = {}
for key, value in values.items():
cleaned_key = str(key or "").strip()
if not cleaned_key:
continue
normalized[cleaned_key] = str(value or "").strip()
for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items():
if normalized.get(canonical_key):
continue
for alias in aliases:
if normalized.get(alias):
normalized[canonical_key] = normalized[alias]
break
return normalized
def normalize_ontology_context_json(context_json: Any) -> dict[str, Any]:
if not isinstance(context_json, dict):
return {}
normalized = dict(context_json)
for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items():
if normalized.get(canonical_key):
continue
for alias in aliases:
if normalized.get(alias):
normalized[canonical_key] = normalized[alias]
break
form_values = normalize_ontology_form_values(normalized.get("review_form_values"))
if form_values:
normalized["review_form_values"] = form_values
return normalized
def is_registered_ontology_context_field(field_name: str) -> bool:
return str(field_name or "").strip() in REGISTERED_ONTOLOGY_CONTEXT_FIELDS

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import hashlib
import mimetypes
import re
import shutil
@@ -85,6 +86,26 @@ class ReceiptFolderService:
if not self._should_persist_source(filename, content):
enriched.append(document)
continue
duplicate_receipt = self.find_duplicate_receipt(
filename=filename,
content=content,
current_user=current_user,
)
if duplicate_receipt is not None:
warning = "已上传过同样的单据,请不要重复上传。"
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
enriched.append(
document.model_copy(
update={
"receipt_id": duplicate_receipt.id,
"receipt_status": duplicate_receipt.status,
"receipt_preview_url": duplicate_receipt.preview_url,
"receipt_source_url": duplicate_receipt.source_url,
"warnings": list(dict.fromkeys([*existing_warnings, warning])),
}
)
)
continue
receipt = self.save_receipt(
filename=filename,
content=content,
@@ -140,6 +161,7 @@ class ReceiptFolderService:
"source_file_name": normalized_name,
"media_type": resolved_media_type,
"size_bytes": len(content),
"file_sha256": self._content_hash(content),
"uploaded_at": now.isoformat(),
"status": "linked" if linked else "unlinked",
"linked_claim_id": str(linked_claim_id or "").strip(),
@@ -243,8 +265,24 @@ class ReceiptFolderService:
],
fields=self._resolve_fields(meta),
raw_meta=meta,
edit_logs=self._resolve_edit_logs(meta),
)
def find_duplicate_receipt(
self,
*,
filename: str,
content: bytes,
current_user: CurrentUserContext,
) -> ReceiptFolderItemRead | None:
if not self._should_persist_source(filename, content):
return None
file_hash = self._content_hash(content)
for meta in self._iter_owner_meta(self._owner_key(current_user)):
if file_hash and str(meta.get("file_sha256") or "").strip() == file_hash:
return self._build_item(meta)
return None
def update_receipt(
self,
*,
@@ -255,6 +293,7 @@ class ReceiptFolderService:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir)
before_meta = json.loads(json.dumps(meta, ensure_ascii=False))
updates = payload.model_dump(exclude_unset=True)
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
if key in updates and updates[key] is not None:
@@ -270,6 +309,18 @@ class ReceiptFolderService:
for field in payload.fields or []
]
meta["editable_fields"] = editable
changes = self._build_edit_changes(before_meta, meta)
if changes:
logs = list(meta.get("edit_logs") or [])
logs.insert(
0,
{
"operated_at": datetime.now(UTC).isoformat(),
"operator": self._operator_label(current_user),
"changes": changes,
},
)
meta["edit_logs"] = logs[:50]
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta)
return self.get_receipt(receipt_id, current_user)
@@ -285,6 +336,23 @@ class ReceiptFolderService:
shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
def delete_receipts_for_claim(self, claim_id: str) -> int:
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return 0
deleted_count = 0
self.root.mkdir(parents=True, exist_ok=True)
for meta_path in list(self.root.glob("*/*/meta.json")):
try:
meta = self._read_meta(meta_path.parent)
except FileNotFoundError:
continue
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
continue
shutil.rmtree(meta_path.parent, ignore_errors=True)
deleted_count += 1
return deleted_count
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
@@ -501,6 +569,14 @@ class ReceiptFolderService:
encoding="utf-8",
)
@staticmethod
def _content_hash(content: bytes) -> str:
return hashlib.sha256(content or b"").hexdigest() if content else ""
@staticmethod
def _operator_label(current_user: CurrentUserContext) -> str:
return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户"
@staticmethod
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
if status_filter in {"", "all"}:
@@ -557,6 +633,97 @@ class ReceiptFolderService:
]
return fields
def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]:
logs = []
for log in list(meta.get("edit_logs") or []):
if not isinstance(log, dict):
continue
changes = [
{
"key": str(change.get("key") or ""),
"label": str(change.get("label") or ""),
"before": str(change.get("before") or ""),
"after": str(change.get("after") or ""),
}
for change in list(log.get("changes") or [])
if isinstance(change, dict)
and str(change.get("label") or change.get("key") or "").strip()
]
if not changes:
continue
logs.append(
{
"operated_at": self._parse_datetime(log.get("operated_at")),
"operator": str(log.get("operator") or "当前用户").strip() or "当前用户",
"changes": changes,
}
)
return logs
def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]:
before_values = self._flatten_editable_receipt_values(before_meta)
after_values = self._flatten_editable_receipt_values(after_meta)
changes = []
for key in sorted(set(before_values) | set(after_values)):
before = before_values.get(key, {})
after = after_values.get(key, {})
before_value = str(before.get("value") or "").strip()
after_value = str(after.get("value") or "").strip()
if before_value == after_value:
continue
label = str(after.get("label") or before.get("label") or key).strip()
changes.append(
{
"key": key,
"label": label,
"before": before_value,
"after": after_value,
}
)
return changes
def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]:
values = {
"document_type_label": {
"label": "票据类型",
"value": str(meta.get("document_type_label") or "").strip(),
},
"scene_label": {
"label": "费用场景",
"value": str(meta.get("scene_label") or "").strip(),
},
"summary": {
"label": "摘要",
"value": str(meta.get("summary") or "").strip(),
},
"amount": {
"label": "金额",
"value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
},
"document_date": {
"label": "票据日期",
"value": self._resolve_receipt_document_date(meta),
},
"merchant_name": {
"label": "商户",
"value": self._resolve_receipt_merchant_name(meta),
},
}
for index, field in enumerate(list(meta.get("document_fields") or [])):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip()
label = str(field.get("label") or "").strip()
value = str(field.get("value") or "").strip()
stable_key = key or f"field_{index}_{label}"
if not stable_key and not label:
continue
values[stable_key] = {
"label": label or stable_key,
"value": value,
}
return values
def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str:
editable = meta.get("editable_fields")
if isinstance(editable, dict):

View File

@@ -202,7 +202,7 @@ def _build_structured_conditions(text: str, fields: list[RiskRuleField]) -> list
field_keys = [field.key for field in fields]
attachment_fields = [key for key in field_keys if key.startswith("attachment.")]
city_left = [key for key in field_keys if key in {"attachment.hotel_city", "attachment.route_cities"}]
city_right = [key for key in field_keys if key in {"claim.location", "item.item_location", "employee.location"}]
city_right = [key for key in field_keys if key in {"claim.location", "item.item_location"}]
date_fields = [key for key in field_keys if _field_type(key, fields) == "date" and key.startswith("attachment.")]
range_start = [key for key in field_keys if key in {"claim.trip_start_date", "item.item_date"}]
range_end = [key for key in field_keys if key in {"claim.trip_end_date", "item.item_date"}]

View File

@@ -65,9 +65,10 @@ def _build_condition_steps(manifest: dict[str, Any], evidence: dict[str, Any]) -
),
"operator": "route_city_consistency",
"inputs": {
"application_reference_values": city_consistency.get("application_reference_values") or [],
"claim_reference_values": city_consistency.get("claim_reference_values") or [],
"attachment_values": city_consistency.get("attachment_values") or [],
"reference_values": city_consistency.get("reference_values") or [],
"home_values": city_consistency.get("home_values") or [],
"unexpected_route_cities": city_consistency.get("unexpected_route_cities") or [],
"explanation_hits": city_consistency.get("explanation_hits") or [],
},

View File

@@ -621,7 +621,6 @@ class RiskRuleGenerationService:
in {
"claim.reason",
"claim.location",
"employee.location",
"item.item_date",
"item.item_reason",
"item.item_location",

View File

@@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
"员工常驻地",
"text",
"employee",
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
("常驻地", "办公地", "员工所在地", "所在城市"),
),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),

View File

@@ -105,9 +105,10 @@ def build_risk_rule_compiler_messages(
"重复发票、同一票据号、重复报销等规则必须用 duplicate_value例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。",
"差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。",
"申报目的地和明细发生地点属于申报行程城市集合。",
"员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地",
"员工常驻地只能作为员工档案背景,不能作为本次出发地或返回地的硬依据",
"本次出发地和返回地应来自申请单明确字段或交通票路线本身。",
"绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。",
"如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。",
"如果票据路线出现无法由本次票据起终点和申报目的地解释的额外城市,应描述为中途周转/绕行异常。",
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
"不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。",
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
@@ -128,13 +129,12 @@ def build_risk_rule_compiler_messages(
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理起终点;A与B无交集且无合理说明或A中出现BC之外城市时命中。"
"A与B无交集且无合理说明或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],

View File

@@ -19,7 +19,7 @@ RISK_LEVEL_LABELS = {
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location")
CITY_HOME_FIELDS = ("employee.location",)
CITY_HOME_FIELDS: tuple[str, ...] = ()
CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason")
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
@@ -64,8 +64,9 @@ def build_city_consistency_draft(
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
condition_summary = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
"若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
)
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
return {
@@ -79,9 +80,9 @@ def build_city_consistency_draft(
"flow": {
**flow,
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
"evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市",
"pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
"evidence": "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市",
"pass": "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
},
}
@@ -102,16 +103,15 @@ def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
"formula": (
"A=UNION(attachment.route_cities, attachment.hotel_city); "
"B=UNION(claim.location, item.item_location); "
"C=UNION(employee.location); "
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
"OR EXISTS(city IN A WHERE city NOT IN BC)"
"OR EXISTS(city IN route_cities WHERE city NOT EXPLAINED BY B OR ROUTE_ENDPOINTS)"
),
"conditions": [
{
"left_group": list(CITY_ATTACHMENT_FIELDS),
"operator": "route_city_consistency",
"right_group": list(CITY_REFERENCE_FIELDS),
"home_group": list(CITY_HOME_FIELDS),
"home_group": [],
"exception_fields": list(CITY_EXCEPTION_FIELDS),
"exception_keywords": exception_keywords,
}

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any
BUDGET_RISK_STAGES = {"budget_execution", "budget_control", "budget_review"}
def is_budget_risk_manifest(manifest: dict[str, Any]) -> bool:
"""判断规则是否属于预算治理风险,而不是普通费用行为风险。"""
if not isinstance(manifest, dict):
return False
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
rule_code = str(manifest.get("rule_code") or "").strip().lower()
finance_rule_code = str(
manifest.get("finance_rule_code") or metadata.get("finance_rule_code") or ""
).strip().lower()
if rule_code.startswith("risk.budget.") or rule_code.startswith("budget."):
return True
if finance_rule_code.startswith("budget."):
return True
if _normalized_text(manifest.get("risk_domain") or metadata.get("risk_domain")) == "budget":
return True
domains = {_normalized_text(value) for value in _as_list(applies_to.get("domains"))}
if "budget" in domains and not domains.difference({"budget"}):
return True
stages = {
_normalized_text(value)
for value in [
*_as_list(manifest.get("business_stage")),
*_as_list(metadata.get("business_stage")),
*_as_list(applies_to.get("business_stages")),
]
}
if stages & BUDGET_RISK_STAGES:
return True
category_text = " ".join(
str(value or "")
for value in (
manifest.get("risk_category"),
metadata.get("risk_category"),
manifest.get("name"),
)
)
if "预算" in category_text and any(key.startswith("budget.") for key in _iter_field_keys(manifest)):
return True
return any(key.startswith("budget.") for key in _iter_field_keys(manifest))
def _iter_field_keys(value: Any) -> list[str]:
keys: list[str] = []
def visit(node: Any) -> None:
if isinstance(node, dict):
for key, item in node.items():
normalized_key = str(key or "").strip()
if normalized_key in {
"key",
"field",
"left",
"right",
"field_key",
"fieldKey",
}:
_append_key(item)
elif normalized_key in {
"fields",
"field_keys",
"fieldKeys",
"search_fields",
"searchFields",
"left_fields",
"leftFields",
"right_fields",
"rightFields",
"left_group",
"leftGroup",
"right_group",
"rightGroup",
"date_fields",
"range_start_fields",
"range_end_fields",
}:
for child in _as_list(item):
_append_key(child)
visit(item)
return
if isinstance(node, list):
for item in node:
visit(item)
def _append_key(item: Any) -> None:
text = str(item or "").strip().lower()
if text and text not in keys:
keys.append(text)
visit(value)
return keys
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
if value in (None, ""):
return []
return [value]
def _normalized_text(value: Any) -> str:
return str(value or "").strip().lower()

View File

@@ -28,14 +28,15 @@ RISK_LEVEL_LABELS = {
CITY_ROUTE_CONDITION_SUMMARY = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
"若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
)
CITY_ROUTE_FLOW_DECISION = (
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市"
"附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市"
)
CITY_ROUTE_FLOW_EVIDENCE = (
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
"读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
)
@@ -82,7 +83,7 @@ def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
)
flow.setdefault(
"pass",
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
"票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
)
flow["fail"] = (
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"

View File

@@ -212,14 +212,13 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"requires_attachment": True,
"natural_language": (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"再读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系,"
"或票据路线中出现申报目的地与员工常驻地之外的额外中转城市,"
"或票据路线中出现无法由本次票据起终点和申报目的地解释的额外中转城市,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,"
"则标记为高风险,要求补充行程说明或退回修改。"
),
"field_keys": [
"employee.location",
"claim.location",
"item.item_location",
"attachment.route_cities",
@@ -236,7 +235,7 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"id": "city_outside_business_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.route_cities", "attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "employee.location"],
"right_fields": ["claim.location", "item.item_location"],
},
{
"id": "missing_route_exception",

View File

@@ -198,25 +198,23 @@ class RiskRuleTemplateExecutor:
for key in field_keys
if key in {"attachment.route_cities", "attachment.hotel_city"}
] or ["attachment.route_cities", "attachment.hotel_city"]
home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"]
reference_values: list[str] = []
application_reference_values: list[str] = []
attachment_values: list[str] = []
home_values: list[str] = []
route_values: list[str] = []
for key in reference_keys:
reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
application_reference_values.extend(self._iter_application_location_values(claim))
reference_values.extend(application_reference_values)
for key in attachment_keys:
resolved = self._resolve_values(key, claim=claim, contexts=contexts)
attachment_values.extend(resolved)
if key == "attachment.route_cities":
route_values.extend(resolved)
for key in home_keys:
home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
route_sequence_values = list(route_values)
reference_values = self._dedupe_values(reference_values)
application_reference_values = self._dedupe_values(application_reference_values)
attachment_values = self._dedupe_values(attachment_values)
home_values = self._dedupe_values(home_values)
route_values = self._dedupe_values(route_values)
if not reference_values or not attachment_values:
return None
@@ -239,9 +237,8 @@ class RiskRuleTemplateExecutor:
if keyword and keyword in explanation_corpus
]
unexpected_route_cities = self._resolve_unexpected_route_cities(
route_values,
route_sequence_values,
reference_values=reference_values,
home_values=home_values,
)
has_destination_overlap = self._condition_passes(
"overlap",
@@ -252,7 +249,7 @@ class RiskRuleTemplateExecutor:
return None
reason = (
"票据路线包含申报行程和常驻地之外的中转城市。"
"票据路线包含无法由申请单、报销单或附件起终点解释的额外城市。"
if unexpected_route_cities
else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。"
)
@@ -280,9 +277,15 @@ class RiskRuleTemplateExecutor:
"reasonable_exception": bool(keyword_hits),
},
"city_consistency": {
"application_reference_values": application_reference_values[:8],
"claim_reference_values": self._dedupe_values(
[
*self._resolve_values("claim.location", claim=claim, contexts=contexts),
*self._resolve_values("item.item_location", claim=claim, contexts=contexts),
]
)[:8],
"attachment_values": attachment_values[:8],
"reference_values": reference_values[:8],
"home_values": home_values[:8],
"route_values": route_values[:8],
"unexpected_route_cities": unexpected_route_cities[:8],
"explanation_keywords": explanation_keywords[:8],
@@ -609,14 +612,19 @@ class RiskRuleTemplateExecutor:
route_values: list[str],
*,
reference_values: list[str],
home_values: list[str],
) -> list[str]:
if len(route_values) < 2:
return []
allowed_values = [value for value in [*reference_values, *home_values] if value]
allowed_values = [value for value in reference_values if value]
if not allowed_values:
return []
candidates = route_values if home_values else route_values[1:-1]
allowed_values.extend(
RiskRuleTemplateExecutor._resolve_inferred_route_endpoint_values(
route_values,
reference_values=reference_values,
)
)
candidates = route_values
unexpected: list[str] = []
for city in candidates:
if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
@@ -625,6 +633,37 @@ class RiskRuleTemplateExecutor:
unexpected.append(city)
return unexpected
@staticmethod
def _resolve_inferred_route_endpoint_values(
route_values: list[str],
*,
reference_values: list[str],
) -> list[str]:
if len(route_values) < 2 or not reference_values:
return []
has_declared_destination = any(
RiskRuleTemplateExecutor._values_overlap([city], reference_values)
for city in route_values
)
if not has_declared_destination:
return []
inferred: list[str] = []
first_city = str(route_values[0] or "").strip()
last_city = str(route_values[-1] or "").strip()
if first_city:
inferred.append(first_city)
if (
last_city
and (
len(route_values) == 2
or RiskRuleTemplateExecutor._values_overlap([last_city], [first_city])
)
and last_city not in inferred
):
inferred.append(last_city)
return inferred
@staticmethod
def _expand_route_city_values(values: list[Any]) -> list[Any]:
expanded: list[Any] = []
@@ -750,6 +789,56 @@ class RiskRuleTemplateExecutor:
return parsed.year
return None
@staticmethod
def _iter_application_location_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []
application_sources = {"application_detail", "application_handoff", "application_link"}
location_keys = (
"application_location",
"applicationLocation",
"business_location",
"businessLocation",
"location",
"destination",
"destination_city",
"destinationCity",
"matched_city",
"matchedCity",
)
nested_keys = (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
)
for flag in list(getattr(claim, "risk_flags_json", None) or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_anchor = (
source in application_sources
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
or any(
isinstance(flag.get(key), dict)
for key in ("application_detail", "applicationDetail")
)
)
if not has_application_anchor:
continue
sources: list[dict[str, Any]] = [flag]
for key in nested_keys:
nested = flag.get(key)
if isinstance(nested, dict):
sources.append(nested)
for source_dict in sources:
for key in location_keys:
value = source_dict.get(key)
if value not in (None, ""):
values.append(value)
return RiskRuleTemplateExecutor._normalize_values(values)
@staticmethod
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import *
@@ -49,8 +50,8 @@ class UserAgentReviewCoreMixin:
return False
if str(payload.context_json.get("review_action") or "").strip():
return False
review_form_values = self._resolve_review_form_values(payload)
if str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip():
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
if str(review_form_values.get("expense_type") or "").strip():
return False
if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload):
return False

View File

@@ -38,6 +38,7 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, Runtime
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.expense_type_keywords import resolve_expense_type_label_from_text
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.user_agent_constants import *
@@ -151,10 +152,9 @@ class UserAgentReviewSlotMixin:
def _resolve_location_value(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return value
value = str(review_form_values.get("location") or "").strip()
if value:
return value
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
request_context = payload.context_json.get("request_context")
@@ -181,21 +181,7 @@ class UserAgentReviewSlotMixin:
@staticmethod
def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]:
values = payload.context_json.get("review_form_values")
if not isinstance(values, dict):
return {}
normalized: dict[str, str] = {}
for key, value in values.items():
cleaned_key = str(key or "").strip()
if not cleaned_key:
continue
normalized[cleaned_key] = str(value or "").strip()
if not normalized.get("transport_mode"):
for alias in ("transportMode", "application_transport_mode", "applicationTransportMode"):
if normalized.get(alias):
normalized["transport_mode"] = normalized[alias]
break
return normalized
return normalize_ontology_form_values(payload.context_json.get("review_form_values"))
@staticmethod
@@ -220,12 +206,7 @@ class UserAgentReviewSlotMixin:
def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
edited_value = str(review_form_values.get("time_range") or "").strip()
if edited_value:
raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip()
return self._build_slot_value(
@@ -237,17 +218,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_time = str(review_form_values.get("application_business_time") or "").strip()
if application_time:
return self._build_slot_value(
value=application_time,
raw_value=application_time,
normalized_value=application_time,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的发生时间依据。",
)
time_range = payload.ontology.time_range
if time_range.start_date and time_range.end_date:
normalized_value = (
@@ -270,25 +240,14 @@ class UserAgentReviewSlotMixin:
def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return self._build_slot_value(
value=value,
normalized_value=value,
source="user_form",
confidence=1.0,
evidence="来源于用户修改后的结构化表单。",
)
application_location = str(review_form_values.get("application_location") or "").strip()
if application_location:
value = str(review_form_values.get("location") or "").strip()
if value:
return self._build_slot_value(
value=application_location,
normalized_value=application_location,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的地点依据",
value=value,
normalized_value=value,
source="user_form",
confidence=1.0,
evidence="来源于用户修改后的结构化表单",
)
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
@@ -396,17 +355,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_reason = str(review_form_values.get("application_reason") or "").strip()
if application_reason:
return self._build_slot_value(
value=application_reason,
raw_value=application_reason,
normalized_value=application_reason,
source="detail_context",
confidence=0.9,
evidence="来源于已关联申请单,作为本次报销草稿的事由依据。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
@@ -457,22 +405,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_amount = str(
review_form_values.get("application_amount")
or review_form_values.get("application_amount_label")
or ""
).strip()
if application_amount:
normalized = self._normalize_amount_text(application_amount)
return self._build_slot_value(
value=normalized,
raw_value=application_amount,
normalized_value=normalized,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的金额依据。",
)
amount_value = entity_map.get("amount", "")
if amount_value:
normalized = self._normalize_amount_text(amount_value)
@@ -506,7 +438,7 @@ class UserAgentReviewSlotMixin:
ocr_documents: list[dict[str, object]],
) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip()
edited_value = str(review_form_values.get("expense_type") or "").strip()
if edited_value:
normalized_code, normalized_label = self._normalize_expense_type_input(edited_value)
return self._build_slot_value(
@@ -581,7 +513,7 @@ class UserAgentReviewSlotMixin:
def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
attachment_names = str(review_form_values.get("attachment_names") or "").strip()
attachment_names = str(review_form_values.get("attachments") or "").strip()
if attachment_names:
return self._build_slot_value(
value=attachment_names,

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import *
@@ -422,22 +423,19 @@ class UserAgentReviewTravelReceiptMixin:
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload)
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
parts = [
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(review_form_values.get("reason") or ""),
str(review_form_values.get("business_reason") or ""),
str(review_form_values.get("location") or ""),
str(review_form_values.get("business_location") or ""),
]
return "\n".join(part.strip() for part in parts if part and part.strip())
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
review_form_values = self._resolve_review_form_values(payload)
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
candidates = [
str(review_form_values.get("business_location") or ""),
str(review_form_values.get("location") or ""),
self._resolve_location_value(payload),
str(payload.message or ""),

View File

@@ -202,7 +202,7 @@ def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
assert asset is None or asset.config_json["tag"] == "废弃规则"
def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
def test_demo_budget_risk_rules_are_excluded_from_risk_rule_center() -> None:
with build_session() as db:
service = AgentAssetService(db)
service.list_assets(asset_type=AgentAssetType.RULE.value)
@@ -218,16 +218,7 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
)
)
assert budget_rule is not None
assert budget_rule.scenario_json == ["全部"]
assert budget_rule.config_json["budget_required"] is True
assert budget_rule.config_json["expense_types"] == ["all"]
assert budget_rule.config_json["business_stage"] == [
"expense_application",
"reimbursement",
"budget_execution",
]
assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy"
assert budget_rule is None
assert communication_rule is not None
assert communication_rule.scenario_json == ["通信费"]
@@ -237,6 +228,44 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None:
assert communication_rule.config_json["budget_required"] is True
def test_existing_budget_risk_assets_are_hidden_from_rule_lists() -> None:
with build_session() as db:
db.add(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code="risk.budget.legacy.visible",
name="历史预算风险",
description="旧数据中已经存在的预算风险规则。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["全部"],
owner="pytest",
status=AgentAssetStatus.ACTIVE.value,
config_json={
"detail_mode": "json_risk",
"finance_rule_code": "budget.execution.policy",
"rule_document": {"file_name": "risk.budget.available_balance_insufficient.json"},
},
)
)
db.commit()
service = AgentAssetService(db)
listed_codes = {
item.code for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
}
page = service.list_assets_page(
asset_type=AgentAssetType.RULE.value,
status=None,
domain=None,
keyword=None,
page=1,
page_size=100,
)
assert "risk.budget.legacy.visible" not in listed_codes
assert "risk.budget.legacy.visible" not in {item.code for item in page.items}
def test_agent_asset_service_can_activate_rule_after_review() -> None:
with build_session() as db:
service = AgentAssetService(db)

View File

@@ -14,11 +14,12 @@ from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation
from app.services.budget import BudgetService
from app.services.demo_company_simulation_seed import (
SIM_CLAIM_PREFIX,
SIM_EMPLOYEE_PREFIX,
HalfYearExpenseSimulationSeeder,
SimulationConfig,
)
from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE
from app.services.demo_company_simulation_rebalance import HalfYearExpenseSimulationRebalancer
def build_session() -> Session:
@@ -133,7 +134,7 @@ def test_half_year_simulation_feeds_budget_summary() -> None:
summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
sim_claim_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
sim_employee_count = db.scalar(
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
@@ -178,25 +179,128 @@ def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume()
visible_claim_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
)
total_claim_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
daily_counts = [
row[0]
for row in db.execute(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
.group_by(func.date(ExpenseClaim.occurred_at))
).all()
]
max_daily_count = max(daily_counts) if daily_counts else 0
earliest_claim_day = db.scalar(
select(func.min(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
ExpenseClaim.project_code == SIM_PROJECT_CODE
)
)
latest_claim_day = db.scalar(
select(func.max(ExpenseClaim.occurred_at)).where(
ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%")
ExpenseClaim.project_code == SIM_PROJECT_CODE
)
)
assert admin_claim_count == 0
assert total_claim_count is not None
assert 400 <= total_claim_count <= 500
assert visible_claim_count is not None
assert 400 <= visible_claim_count <= 500
assert 12 <= visible_claim_count <= 30
assert max_daily_count <= 16
assert earliest_claim_day is not None
assert latest_claim_day is not None
assert earliest_claim_day.date() >= date(2026, 1, 1)
assert latest_claim_day.date() <= date(2026, 6, 2)
def test_half_year_simulation_rebalance_spreads_existing_rows_without_deleting() -> None:
with build_session() as db:
seed_company(db)
config = SimulationConfig(
target_employees=100,
start_date=date(2026, 1, 1),
months=6,
seed=20260602,
)
HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
claims = list(
db.scalars(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.claim_no.asc())
).all()
)
for claim in claims:
claim.occurred_at = datetime(2026, 6, 1, 10, tzinfo=UTC)
claim.submitted_at = datetime(2026, 6, 1, 11, tzinfo=UTC)
claim.created_at = claim.occurred_at
claim.updated_at = claim.submitted_at
for item in claim.items:
item.item_date = date(2026, 6, 1)
db.commit()
before_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
preview = HalfYearExpenseSimulationRebalancer(db).preview()
applied = HalfYearExpenseSimulationRebalancer(db).apply()
db.commit()
after_count = db.scalar(
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
)
daily_counts = [
row[0]
for row in db.execute(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.group_by(func.date(ExpenseClaim.occurred_at))
).all()
]
month_keys = {
(claim.occurred_at.year, claim.occurred_at.month)
for claim in db.scalars(
select(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
).all()
}
sample_claim = db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.status != "draft")
.order_by(ExpenseClaim.claim_no.asc())
.limit(1)
)
sample_transaction = db.scalar(
select(BudgetTransaction)
.where(BudgetTransaction.source_id == sample_claim.id)
.limit(1)
)
sample_observation = db.scalar(
select(RiskObservation)
.where(RiskObservation.claim_id == sample_claim.id)
.limit(1)
)
assert before_count == after_count
assert preview.claims == applied.claims == after_count
assert applied.recent_claims <= 24
assert max(daily_counts) <= 16
assert {(2026, month) for month in range(1, 7)}.issubset(month_keys)
if sample_transaction is not None:
assert sample_transaction.source_no == sample_claim.claim_no
assert sample_transaction.created_at.date() == sample_claim.submitted_at.date()
if sample_observation is not None:
assert sample_observation.claim_no == sample_claim.claim_no
assert sample_observation.created_at.date() == sample_claim.submitted_at.date()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
@@ -15,6 +16,7 @@ from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY
@@ -111,6 +113,63 @@ def _add_active_rule_asset(
)
def _add_vague_goods_rule_asset(
db: Session,
manager: AgentAssetRuleLibraryManager,
) -> None:
rule_code = "risk.travel.low.vague_ticket_content"
file_name = f"{rule_code}.json"
payload = {
"schema_version": "2.0",
"rule_code": rule_code,
"name": "差旅票据服务内容笼统低风险",
"description": "票据商品或服务名称过于笼统,提醒补充明细。",
"evaluator": "vague_goods_description",
"enabled": True,
"requires_attachment": True,
"applies_to": {
"domains": ["expense", "travel"],
"expense_types": ["travel"],
"business_stages": ["reimbursement"],
},
"outcomes": {"fail": {"severity": "low", "action": "warning"}},
}
manager.write_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
payload=payload,
)
db.add(
AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=rule_code,
name="差旅票据服务内容笼统低风险",
description="",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["差旅费"],
owner="pytest",
status=AgentAssetStatus.ACTIVE.value,
current_version="v1.0.0",
published_version="v1.0.0",
config_json={
"detail_mode": "json_risk",
"rule_library": RISK_RULES_LIBRARY,
"rule_document": {"file_name": file_name},
},
)
)
def _write_attachment_meta(storage_root, invoice_id: str, meta: dict[str, Any]) -> None:
file_path = storage_root / invoice_id
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(b"attachment")
file_path.with_name(f"{file_path.name}.meta.json").write_text(
f"{json.dumps(meta, ensure_ascii=False, indent=2)}\n",
encoding="utf-8",
)
def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim:
return ExpenseClaim(
claim_no=claim_no,
@@ -162,6 +221,13 @@ def test_platform_risk_rules_are_filtered_by_business_stage_and_category(
business_stage="reimbursement",
message="报账环节规则命中",
)
_add_active_rule_asset(
db,
manager,
rule_code="risk.budget.sample.reimbursement.rule",
business_stage="reimbursement",
message="预算风险规则不应进入行为风险检测",
)
_add_active_rule_asset(
db,
manager,
@@ -297,3 +363,122 @@ def test_reimbursement_item_sync_persists_rule_center_risk_preview(
assert rule_flags[0]["business_stage"] == "reimbursement"
assert rule_flags[0]["visibility_scope"] == "submitter"
assert rule_flags[0]["actionability"] == "fixable_by_submitter"
def test_vague_ticket_content_ignores_clear_hotel_receipt_text(
tmp_path,
monkeypatch,
) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
storage_root = tmp_path / "attachments"
_patch_rule_manager(monkeypatch, manager)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root)
_add_vague_goods_rule_asset(db, manager)
invoice_id = "claim-clear-hotel/item-hotel/hotel.jpg"
claim = _build_claim(claim_no="RE-CLEAR-HOTEL-001", expense_type="travel")
claim.invoice_count = 1
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 23),
item_type="hotel_ticket",
item_reason="上海喜来登酒店",
item_location="上海",
item_amount=Decimal("828.00"),
invoice_id=invoice_id,
)
]
db.add(claim)
db.commit()
_write_attachment_meta(
storage_root,
invoice_id,
{
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{"key": "amount", "label": "金额", "value": "828元"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
{"key": "merchant_name", "label": "商户", "value": "上海喜来登酒店"},
],
},
"ocr_summary": "上海喜来登酒店;住宿发票",
"ocr_text": "本发票仅含住宿费,不含其他增值服务费。",
},
)
review = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
assert not [
flag
for flag in review["flags"]
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.low.vague_ticket_content"
]
def test_vague_ticket_content_still_flags_unclear_goods_name(
tmp_path,
monkeypatch,
) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
storage_root = tmp_path / "attachments"
_patch_rule_manager(monkeypatch, manager)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: storage_root)
_add_vague_goods_rule_asset(db, manager)
invoice_id = "claim-vague/item-other/other.pdf"
claim = _build_claim(claim_no="RE-VAGUE-001", expense_type="travel")
claim.invoice_count = 1
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 23),
item_type="other",
item_reason="差旅相关补充票据",
item_location="上海",
item_amount=Decimal("200.00"),
invoice_id=invoice_id,
)
]
db.add(claim)
db.commit()
_write_attachment_meta(
storage_root,
invoice_id,
{
"document_info": {
"document_type": "other",
"document_type_label": "其他单据",
"scene_code": "other",
"scene_label": "其他票据",
"fields": [
{"key": "goods_name", "label": "商品或服务名称", "value": "服务费"},
],
},
"ocr_summary": "费用发票",
"ocr_text": "项目:服务费。",
},
)
review = ExpenseClaimService(db).evaluate_platform_risk_rules(
claim,
business_stage="reimbursement",
)
rule_flags = [
flag
for flag in review["flags"]
if isinstance(flag, dict)
and flag.get("rule_code") == "risk.travel.low.vague_ticket_content"
]
assert len(rule_flags) == 1
assert rule_flags[0]["severity"] == "low"
assert rule_flags[0]["evidence"]["matched_keywords"] == ["服务费"]

View File

@@ -1375,6 +1375,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
payload=ExpenseClaimItemUpdate(
item_reason="",
item_location="",
item_note="票据行程存在改签,已核对业务真实发生。",
item_amount=Decimal("0.00"),
),
current_user=current_user,
@@ -1385,6 +1386,7 @@ def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
assert claim.items[0].item_date == date(2026, 5, 13)
assert claim.items[0].item_reason == ""
assert claim.items[0].item_location == ""
assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。"
assert claim.items[0].item_amount == Decimal("0.00")
@@ -1606,7 +1608,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
service = ExpenseClaimService(db)
updated = service.create_claim_item(
claim_id=claim.id,
payload=ExpenseClaimItemCreate(),
payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
current_user=current_user,
)
@@ -1619,6 +1621,7 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() ->
assert new_item.item_type == "office"
assert new_item.item_reason == ""
assert new_item.item_location == ""
assert new_item.item_note == "待上传异常票据说明"
assert new_item.item_amount == Decimal("0.00")
assert new_item.invoice_id is None
@@ -2808,6 +2811,154 @@ def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
)
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
monkeypatch,
tmp_path,
) -> None:
current_user = CurrentUserContext(
username="emp-round-trip@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
documents: list[OcrRecognizeDocumentRead] = []
for filename, _, media_type in files:
if filename == "outbound.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00",
summary="武汉到上海高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "武汉-上海"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-20"},
],
warnings=[],
)
)
elif filename == "return.png":
documents.append(
OcrRecognizeDocumentRead(
filename=filename,
media_type=media_type or "image/png",
text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00",
summary="上海到武汉高铁票",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="铁路电子客票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
{"key": "route", "label": "行程", "value": "上海-武汉"},
{"key": "amount", "label": "金额", "value": "354元"},
{"key": "date", "label": "日期", "value": "2026-02-23"},
],
warnings=[],
)
)
return OcrRecognizeBatchRead(
total_file_count=len(files),
success_count=len(documents),
documents=documents,
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
manager = Employee(
employee_no="E7210",
name="李经理",
email="manager-round-trip@example.com",
)
employee = Employee(
employee_no="E7211",
name="张三",
email="emp-round-trip@example.com",
grade="P4",
location="上海",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.reason = "支撑国网仿生产环境部署"
claim.employee = employee
claim.employee_id = employee.id
claim.items = [
ExpenseClaimItem(
id="round-trip-item-1",
claim_id=claim.id,
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
ExpenseClaimItem(
id="round-trip-item-2",
claim_id=claim.id,
item_date=date(2026, 2, 23),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
invoice_id=None,
),
]
claim.amount = Decimal("708.00")
claim.invoice_count = 0
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-1",
filename="outbound.png",
content=b"outbound-image",
media_type="image/png",
current_user=current_user,
)
service.upload_claim_item_attachment(
claim_id=claim.id,
item_id="round-trip-item-2",
filename="return.png",
content=b"return-image",
media_type="image/png",
current_user=current_user,
)
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert not any(
isinstance(flag, dict)
and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch"
for flag in list(submitted.risk_flags_json or [])
)
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
monkeypatch,
tmp_path,
@@ -4051,6 +4202,44 @@ def test_application_submit_blocks_when_budget_insufficient_without_state_change
assert db.query(BudgetTransaction).count() == 0
def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None:
current_user = CurrentUserContext(
username="reimbursement-budget-risk@example.com",
name="张三",
role_codes=["employee"],
is_admin=True,
)
with build_session() as db:
_seed_budget_allocation(
db,
department_id="dept-1",
department_name="市场部",
subject_code="office",
amount=Decimal("1000.00"),
)
claim = build_claim(expense_type="office", location="待补充")
claim.amount = Decimal("1200.00")
claim.items[0].item_amount = Decimal("1200.00")
db.add(claim)
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.submitted_at is not None
assert any(
isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_insufficient"
and flag.get("business_stage") == "reimbursement"
for flag in submitted.risk_flags_json
)
assert db.query(BudgetReservation).count() == 0
assert db.query(BudgetTransaction).count() == 0
def test_application_submit_skips_budget_for_non_demo_subject() -> None:
current_user = CurrentUserContext(
username="application-budget-skip@example.com",

View File

@@ -332,6 +332,8 @@ def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> N
assert "budget pressure" not in str(dashboard.exception_mix).lower()
assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0
assert sum(series["data"][-1] for series in dashboard.trend["categoryAmountSeries"]) == 700.0
assert "travel_application" not in str(dashboard.trend["categoryAmountSeries"])
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "Market"
assert dashboard.department_ranking[0]["amount"] == 700.0

View File

@@ -123,6 +123,17 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path
repeated_document = repeated_response.json()["documents"][0]
assert repeated_document["receipt_id"] == receipt_id
duplicate_response = client.post(
"/api/v1/ocr/recognize",
headers=auth_headers,
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
)
assert duplicate_response.status_code == 200
duplicate_document = duplicate_response.json()["documents"][0]
assert duplicate_document["receipt_id"] == receipt_id
assert duplicate_document["receipt_status"] == "unlinked"
assert any("重复上传" in warning for warning in duplicate_document["warnings"])
all_receipts_response = client.get("/api/v1/receipt-folder?status=all", headers=auth_headers)
assert all_receipts_response.status_code == 200
assert len(all_receipts_response.json()) == 1
@@ -143,9 +154,16 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path
},
)
assert update_response.status_code == 200
updated_payload = update_response.json()
assert update_response.json()["document_type_label"] == "电子发票"
assert update_response.json()["amount"] == "108元"
assert updated_payload["edit_logs"]
assert any(
change["after"] == updated_payload["amount"]
for change in updated_payload["edit_logs"][0]["changes"]
)
preview_response = client.get(f"/api/v1/receipt-folder/{receipt_id}/preview", headers=auth_headers)
assert preview_response.status_code == 200
assert preview_response.content == b"fake-image"

View File

@@ -13,6 +13,7 @@ from app.api.deps import get_db
from app.db.base import Base
from app.schemas.ontology import OntologyParseRequest
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
from app.services.ontology_field_registry import normalize_ontology_context_json
from app.services.runtime_chat import RuntimeChatCallTrace, RuntimeChatResult
@@ -866,6 +867,64 @@ def test_semantic_ontology_service_treats_application_session_as_application_con
assert "amount" in result.missing_slots
def test_semantic_ontology_service_normalizes_business_aliases_to_ontology_fields(
monkeypatch,
) -> None:
session_factory = build_session_factory()
with session_factory() as db:
service = SemanticOntologyService(db)
monkeypatch.setattr(
service,
"_parse_with_model",
lambda **kwargs: (None, [], "model_disabled_for_field_registry_test"),
)
result = service.parse(
OntologyParseRequest(
query="生成差旅费报销草稿",
user_id="pytest",
context_json={
"review_action": "save_draft",
"review_form_values": {
"reimbursement_type": "差旅费",
"business_time": "2026-06-01 至 2026-06-03",
"business_location": "上海",
"reason_value": "支撑国网仿生产环境部署",
"application_amount": "3000元",
"transport_type": "火车",
},
},
)
)
entity_map = {(item.type, item.normalized_value) for item in result.entities}
assert ("transport_mode", "火车") in entity_map
assert ("reason", "支撑国网仿生产环境部署") in entity_map
assert ("location", "上海") in entity_map
assert "time_range" not in result.missing_slots
assert "reason" not in result.missing_slots
def test_ontology_context_normalizes_employee_profile_aliases() -> None:
context = normalize_ontology_context_json(
{
"name": "曹笑竹",
"department": "技术部",
"position": "财务智能化产品经理",
"grade": "P5",
"managerName": "向万红",
"costCenter": "TECH-DEPT",
}
)
assert context["employee_name"] == "曹笑竹"
assert context["department_name"] == "技术部"
assert context["employee_position"] == "财务智能化产品经理"
assert context["employee_grade"] == "P5"
assert context["manager_name"] == "向万红"
assert context["cost_center"] == "TECH-DEPT"
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import pytest
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead
@@ -67,3 +69,41 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
assert fields["列车出发时间"] == "2026-02-20 08:30"
finally:
get_settings.cache_clear()
def test_receipt_folder_delete_receipts_for_claim_removes_linked_receipts(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
service = ReceiptFolderService()
receipt = service.save_receipt(
filename="linked-receipt.pdf",
content=b"%PDF-1.4 linked",
media_type="application/pdf",
current_user=current_user,
linked_claim_id="claim-1",
linked_claim_no="RE-001",
linked_item_id="item-1",
document=OcrRecognizeDocumentRead(
filename="linked-receipt.pdf",
media_type="application/pdf",
text="invoice number 123 amount 100",
document_type="vat_invoice",
document_type_label="invoice",
scene_code="other",
scene_label="receipt",
),
)
assert service.get_receipt(receipt.id, current_user).linked_claim_id == "claim-1"
assert service.delete_receipts_for_claim("claim-1") == 1
with pytest.raises(FileNotFoundError):
service.get_receipt(receipt.id, current_user)
finally:
get_settings.cache_clear()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -33,6 +34,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_assets import AgentAssetService
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_flow_diagram import (
RiskRuleFlowDiagramRenderer,
RiskRuleFlowDiagramSpec,
@@ -62,13 +64,12 @@ class TravelRouteSemanticRuntimeChatService:
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地;A与B无交集且无合理说明或A出现BC之外城市时命中。"
"A与B无交集且无合理说明或A出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
@@ -577,6 +578,39 @@ def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
assert "#10a37f" not in high_svg
def test_non_budget_platform_risk_manifests_do_not_use_budget_or_employee_location() -> None:
rule_root = Path("server/rules/risk-rules")
checked = 0
for path in sorted(rule_root.glob("*.json")):
payload = json.loads(path.read_text(encoding="utf-8"))
if is_budget_risk_manifest(payload):
continue
checked += 1
normalized = normalize_risk_rule_manifest(payload)
params = normalized.get("params") if isinstance(normalized.get("params"), dict) else {}
text_blob = json.dumps(normalized, ensure_ascii=False)
home_city_fields = params.get("home_city_fields")
condition_summary = str(
normalized.get("condition_summary") or params.get("condition_summary") or ""
)
template_key = str(
normalized.get("template_key") or params.get("template_key") or ""
).strip()
looks_like_city_rule = any(token in text_blob for token in ("城市", "目的地", "行程城市"))
assert "budget." not in text_blob, path.name
assert "employee.location" not in text_blob, path.name
assert not (
isinstance(home_city_fields, list)
and any(str(item or "").strip() for item in home_city_fields)
), path.name
assert "风险关键词" not in condition_summary, path.name
assert not (template_key == "keyword_match_v1" and looks_like_city_rule), path.name
assert checked == 28
def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
with build_session() as db:
service = AgentAssetService(db)
@@ -742,6 +776,280 @@ def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_dest
assert result is None
def test_travel_route_city_consistency_allows_inferred_round_trip_origin() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-INFERRED-ROUND-TRIP",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="上海",
amount=Decimal("708.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-INFERRED-ROUND-TRIP-EMP",
name="测试员工",
email="inferred-round-trip@example.com",
location="上海",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
},
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "上海-武汉"}],
},
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
"ocr_summary": "上海到武汉高铁票",
"item": claim.items[0],
},
],
)
assert result is None
def test_travel_route_city_consistency_uses_application_location_not_employee_origin() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-APPLICATION-LOCATION-NO-FALSE-POSITIVE",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="待补充",
amount=Decimal("354.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
risk_flags_json=[
{
"source": "application_link",
"application_claim_no": "AP-202606-LOCAL",
"application_detail": {
"application_location": "上海",
"application_reason": "支撑国网仿生产环境部署",
"application_time": "2026-02-20 至 2026-02-23",
},
}
],
)
claim.employee = Employee(
employee_no="TEST-APPLICATION-LOCATION-EMP",
name="测试员工",
email="application-location@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
}
],
)
assert result is None
def test_travel_route_city_mismatch_evidence_uses_application_claim_and_attachment() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-APPLICATION-LOCATION-MISMATCH",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="去北京参加项目会议",
location="北京",
amount=Decimal("354.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime.now(UTC),
status="draft",
risk_flags_json=[
{
"source": "application_link",
"application_claim_no": "AP-202606-MISMATCH",
"application_detail": {
"application_location": "北京",
"application_reason": "去北京参加项目会议",
"application_time": "2026-02-20 至 2026-02-23",
},
}
],
)
claim.employee = Employee(
employee_no="TEST-APPLICATION-MISMATCH-EMP",
name="测试员工",
email="application-mismatch@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="去北京参加项目会议",
item_location="北京",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "train_ticket",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
"ocr_summary": "武汉到上海高铁票",
"item": claim.items[0],
}
],
)
assert result is not None
evidence = result["evidence"]["city_consistency"]
assert evidence["application_reference_values"] == ["北京"]
assert evidence["claim_reference_values"] == ["北京"]
assert evidence["attachment_values"] == ["武汉", "上海"]
assert evidence["unexpected_route_cities"] == ["武汉", "上海"]
assert "home_values" not in evidence
assert "ignored_employee_context_values" not in evidence
def test_travel_route_city_consistency_still_hits_onward_city_after_destination() -> None:
manifest = normalize_risk_rule_manifest(
AgentAssetRuleLibraryManager().read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name="risk.travel.high.city_mismatch.json",
)
)
claim = ExpenseClaim(
claim_no="TEST-ONWARD-CITY",
employee_name="测试员工",
department_name="测试部门",
expense_type="travel",
reason="支撑国网仿生产环境部署",
location="上海",
amount=Decimal("840.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-ONWARD-CITY-EMP",
name="测试员工",
email="onward-city@example.com",
location="上海",
)
claim.items = [
ExpenseClaimItem(
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产环境部署",
item_location="上海",
item_amount=Decimal("480.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"document_type": "flight_itinerary",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
},
"ocr_text": "电子行程单 2026-02-20 武汉-上海 金额 480元",
"ocr_summary": "武汉到上海机票",
"item": claim.items[0],
},
{
"document_info": {
"document_type": "flight_itinerary",
"scene_code": "travel",
"fields": [{"key": "route", "label": "行程", "value": "上海-成都"}],
},
"ocr_text": "电子行程单 2026-02-21 上海-成都 金额 360元",
"ocr_summary": "上海到成都机票",
"item": claim.items[0],
},
],
)
assert result is not None
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["成都"]
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
@@ -783,7 +1091,7 @@ def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_p
assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
assert "A=交通票行程城市" in payload["params"]["condition_summary"]
assert "风险关键词" not in payload["params"]["condition_summary"]
assert "employee.location" in payload["params"]["field_keys"]
assert "employee.location" not in payload["params"]["field_keys"]
assert "route_anomaly_policy" in payload["params"]
@@ -882,10 +1190,10 @@ def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_exe
)
assert result is not None
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京"]
assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京", "武汉"]
def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning_home() -> None:
def test_travel_route_rule_does_not_use_employee_location_as_allowed_endpoint() -> None:
manifest = {
"template_key": "field_compare_v1",
"params": {
@@ -904,8 +1212,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
"exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
"condition_summary": (
"A=票据路线城市B=申报城市,C=员工常驻地,"
"A中出现BC之外城市则命中。"
"A=票据路线城市B=申报城市,"
"A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。"
),
},
"outcomes": {"fail": {"severity": "high"}},
@@ -962,8 +1270,8 @@ def test_travel_route_rule_hits_unexpected_intermediate_city_even_when_returning
assert result is not None
evidence = result["evidence"]["city_consistency"]
assert evidence["reference_values"] == ["上海"]
assert evidence["home_values"] == ["武汉"]
assert evidence["unexpected_route_cities"] == ["北京"]
assert evidence["unexpected_route_cities"] == ["北京", "武汉"]
assert "home_values" not in evidence
def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None: