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

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