feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View File

@@ -0,0 +1,188 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
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,
)
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def seed_company(db: Session) -> None:
tech = OrganizationUnit(
id="dept-tech",
unit_code="TECH-DEPT",
name="技术部",
unit_type="department",
cost_center="CC-6100",
location="北京",
)
market = OrganizationUnit(
id="dept-market",
unit_code="MARKET-DEPT",
name="市场部",
unit_type="department",
cost_center="CC-4100",
location="上海",
)
db.add_all([tech, market])
for index in range(3):
db.add(
Employee(
id=f"emp-existing-{index}",
employee_no=f"E-EXISTING-{index}",
name=f"现有员工{index}",
email=f"existing-{index}@xf.com",
grade="P5",
position="主管",
organization_unit=tech if index % 2 == 0 else market,
cost_center="CC-6100" if index % 2 == 0 else "CC-4100",
)
)
db.commit()
def test_half_year_simulation_preview_and_apply_are_idempotent() -> None:
with build_session() as db:
seed_company(db)
config = SimulationConfig(target_employees=8, start_date=date(2026, 1, 1), months=6, seed=7)
preview = HalfYearExpenseSimulationSeeder(db, config).preview()
assert preview.mode == "dry-run"
assert preview.current_employee_count == 3
assert preview.employees_to_create == 5
assert preview.claims_to_create >= 24
assert preview.budget_allocations_to_create > 0
assert preview.budget_transactions_to_create > 0
applied = HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
assert applied.mode == "apply"
assert applied.employees_to_create == 5
assert db.scalar(select(func.count()).select_from(Employee)) == 8
assert db.scalar(select(func.count()).select_from(ExpenseClaim)) == applied.claims_to_create
assert (
db.scalar(select(func.count()).select_from(ExpenseClaimItem))
== applied.claim_items_to_create
)
assert (
db.scalar(select(func.count()).select_from(BudgetAllocation))
== applied.budget_allocations_to_create
)
assert (
db.scalar(select(func.count()).select_from(BudgetTransaction))
== applied.budget_transactions_to_create
)
assert (
db.scalar(select(func.count()).select_from(BudgetReservation))
== applied.budget_reservations_to_create
)
assert (
db.scalar(select(func.count()).select_from(RiskObservation))
== applied.risk_observations_to_create
)
repeated = HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
assert repeated.employees_to_create == 0
assert repeated.claims_to_create == 0
assert repeated.budget_allocations_to_create == 0
assert repeated.budget_transactions_to_create == 0
assert repeated.budget_reservations_to_create == 0
assert repeated.risk_observations_to_create == 0
def test_half_year_simulation_feeds_budget_summary() -> None:
with build_session() as db:
seed_company(db)
config = SimulationConfig(
target_employees=10,
start_date=date(2026, 1, 1),
months=6,
seed=11,
)
HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
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}%"))
)
sim_employee_count = db.scalar(
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
)
assert sim_claim_count and sim_claim_count >= 30
assert sim_employee_count == 7
assert summary.trend
assert {item.period_key for item in summary.trend} == {"2026Q1", "2026Q2"}
assert summary.warning_count + summary.over_budget_count > 0
def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() -> None:
with build_session() as db:
seed_company(db)
db.add(
Employee(
id="emp-admin",
employee_no="ADMIN",
name="admin",
email="admin@xf.com",
grade="P8",
position="admin",
)
)
db.commit()
config = SimulationConfig(
target_employees=100,
start_date=date(2026, 1, 1),
months=6,
seed=20260602,
)
HalfYearExpenseSimulationSeeder(db, config).apply()
db.commit()
admin_claim_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.employee_name == "admin")
)
visible_claim_count = db.scalar(
select(func.count())
.select_from(ExpenseClaim)
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
)
assert admin_claim_count == 0
assert visible_claim_count is not None
assert 400 <= visible_claim_count <= 500

View File

@@ -416,6 +416,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
"application_amount": "3000",
"application_amount_label": "¥3,000",
"application_business_time": "2026-02-20 至 2026-02-23",
"application_date": "2026-06-02T00:58:00Z",
"application_days": "4 天",
"application_transport_mode": "火车",
"application_lodging_daily_cap": "600元/天",
"application_subsidy_daily_cap": "120元/天",
"application_transport_policy": "按真实票据复核",
"application_policy_estimate": "交通 1,160元 + 住宿 2,400元 + 补贴 480元",
},
"expense_scene_selection": {
"expense_type": "travel",
@@ -432,6 +439,7 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
assert claim.location == "上海"
assert claim.amount == Decimal("0.00")
assert claim.invoice_count == 0
assert claim.occurred_at.date() == date(2026, 2, 20)
assert claim.items == []
link_flag = next(
flag
@@ -439,7 +447,221 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
if isinstance(flag, dict) and flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
assert link_flag["application_detail"]["application_amount"] == "3000"
assert link_flag["application_detail"]["application_days"] == "4 天"
assert link_flag["application_detail"]["application_transport_mode"] == "火车"
assert link_flag["application_detail"]["application_lodging_daily_cap"] == "600元/天"
assert link_flag["application_detail"]["application_subsidy_daily_cap"] == "120元/天"
def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> None:
user_id = "linked-application-existing-placeholder@example.com"
message = (
"报销类型:差旅费\n"
"关联申请单AP-202606-002 / 支撑国网仿生产服务器部署 / 上海 / ¥3,000\n"
"报销票据:草稿生成后在详情中上传"
)
with build_session() as db:
employee = Employee(
employee_no="E5105",
name="关联员工",
email=user_id,
grade="P5",
)
db.add(employee)
db.flush()
existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id,
employee_name="关联员工",
department_name="技术部",
project_code=None,
expense_type="travel",
reason="支撑国网仿生产服务器部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
existing_claim.items = [
ExpenseClaimItem(
claim_id=existing_claim.id,
item_date=date(2026, 2, 20),
item_type="travel",
item_reason="支撑国网仿生产服务器部署",
item_location="上海",
item_amount=Decimal("3000.00"),
invoice_id=None,
)
]
db.add(existing_claim)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "关联员工",
"draft_claim_id": existing_claim.id,
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "差旅费",
"amount": "¥3,000",
"reason": "支撑国网仿生产服务器部署",
"location": "上海",
"business_location": "上海",
"application_claim_id": "application-linked-existing-placeholder",
"application_claim_no": "AP-202606-002",
"application_reason": "支撑国网仿生产服务器部署",
"application_location": "上海",
"application_amount": "3000",
"application_amount_label": "¥3,000",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-linked-existing-placeholder",
"application_claim_no": "AP-202606-002",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.id == existing_claim.id
assert claim.amount == Decimal("0.00")
assert claim.invoice_count == 0
assert claim.items == []
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
with build_session() as db:
employee = Employee(
employee_no="E5106",
name="关联差旅员工",
email="linked-application-allowance@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.employee_id = employee.id
claim.employee_name = employee.name
claim.amount = Decimal("354.00")
claim.items[0].item_date = date(2026, 2, 20)
claim.items[0].item_type = "train_ticket"
claim.items[0].item_reason = "武汉-上海"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("354.00")
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_no": "AP-202606-003",
"application_detail": {
"application_time": "2026-02-20 至 2026-02-23",
"application_days": "4 天",
"application_location": "上海",
"application_transport_mode": "火车",
},
}
]
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service._sync_claim_from_items(claim)
db.commit()
db.refresh(claim)
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert "4天" in allowance_item.item_reason
assert allowance_item.item_date == date(2026, 2, 23)
def test_sync_travel_allowance_backfills_range_from_linked_application_claim() -> None:
with build_session() as db:
employee = Employee(
employee_no="E5107",
name="旧关联差旅员工",
email="linked-application-allowance-backfill@example.com",
grade="P4",
)
db.add(employee)
db.flush()
application_claim = ExpenseClaim(
claim_no="AP-202606-004",
employee_id=employee.id,
employee_name=employee.name,
department_name="技术部",
project_code=None,
expense_type="travel_application",
reason="支撑国网仿生产环境部署",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="approved",
approval_stage="审批完成",
risk_flags_json=[
{
"source": "application_detail",
"application_detail": {
"time": "2026-02-20 至 2026-02-23",
"days": "4 天",
"location": "上海",
"reason": "支撑国网仿生产环境部署",
"transport_mode": "火车",
},
}
],
)
db.add(application_claim)
db.flush()
claim = build_claim(expense_type="travel", location="上海")
claim.employee_id = employee.id
claim.employee_name = employee.name
claim.amount = Decimal("354.00")
claim.items[0].item_date = date(2026, 2, 20)
claim.items[0].item_type = "train_ticket"
claim.items[0].item_reason = "武汉-上海"
claim.items[0].item_location = "上海"
claim.items[0].item_amount = Decimal("354.00")
claim.risk_flags_json = [
{
"source": "application_link",
"application_claim_no": "AP-202606-004",
}
]
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
service._sync_claim_from_items(claim)
db.commit()
db.refresh(claim)
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
assert "4天" in allowance_item.item_reason
assert allowance_item.item_date == date(2026, 2, 23)
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
@@ -2858,6 +3080,83 @@ def test_list_claims_limits_finance_to_personal_records() -> None:
assert claims[0].claim_no == "EXP-FIN-OWN"
def test_list_claims_returns_company_reimbursements_for_finance_document_center() -> None:
current_user = CurrentUserContext(
username="finance@example.com",
name="财务",
role_codes=["finance"],
is_admin=False,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="EXP-FIN-COMPANY-SUBMITTED",
employee_name="",
department_name="市场部",
project_code="PRJ-MKT",
expense_type="travel",
reason="客户拜访差旅",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-FIN-COMPANY-DRAFT",
employee_name="",
department_name="技术部",
project_code="PRJ-TECH",
expense_type="office",
reason="办公用品",
location="北京",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-FIN-COMPANY-PAID",
employee_name="",
department_name="财务部",
project_code="PRJ-FIN",
expense_type="meal",
reason="客户沟通",
location="杭州",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="payment",
risk_flags_json=[],
),
]
)
db.commit()
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
archived_nos = {
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
}
assert "EXP-FIN-COMPANY-SUBMITTED" in claim_nos
assert "EXP-FIN-COMPANY-DRAFT" not in claim_nos
assert "EXP-FIN-COMPANY-PAID" not in claim_nos
assert "EXP-FIN-COMPANY-PAID" in archived_nos
def test_list_claims_limits_executive_to_personal_records() -> None:
current_user = CurrentUserContext(
username="executive@example.com",
@@ -3822,6 +4121,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
"reason": "支撑国网服务器上线部署",
"days": "3 天",
"transport_mode": "高铁",
"lodging_daily_cap": "600元/天",
"subsidy_daily_cap": "120元/天",
"transport_policy": "按真实票据复核",
"policy_estimate": "交通按真实票据 + 住宿 1,800元 + 补贴 360元",
"rule_name": "差旅标准规则",
"rule_version": "2026.05",
"amount": "12000.00",
},
},
@@ -3899,6 +4204,13 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
and flag.get("application_detail", {}).get("application_days") == "3 天"
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
and flag.get("application_detail", {}).get("application_policy_estimate")
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
and flag.get("application_detail", {}).get("application_rule_version") == "2026.05"
and flag.get("leader_opinion") == "业务必要,同意申请。"
and flag.get("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json

View File

@@ -0,0 +1,52 @@
from app.services.expense_claim_status_registry import (
claim_status_code,
normalize_expense_claim_state,
)
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
FINANCE_APPROVAL_STAGE,
PAYMENT_PAID_STAGE,
PAYMENT_PENDING_STAGE,
)
def test_normalize_legacy_finance_review_to_submitted_finance_stage() -> None:
state = normalize_expense_claim_state(
"finance_review",
"finance_review",
claim_no="SIM-EXP-2026-0001",
expense_type="travel",
)
assert state.status == "submitted"
assert state.approval_stage == FINANCE_APPROVAL_STAGE
assert state.status_code == 20
assert state.changed is True
def test_normalize_reimbursement_archive_stage_differs_from_application_done() -> None:
reimbursement_state = normalize_expense_claim_state(
"approved",
"completed",
claim_no="SIM-EXP-2026-0002",
expense_type="travel",
)
application_state = normalize_expense_claim_state(
"approved",
"completed",
claim_no="AP-20260602-0001",
expense_type="travel_application",
)
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
assert application_state.approval_stage == APPROVAL_DONE_STAGE
def test_normalize_payment_stages_by_status() -> None:
pending_state = normalize_expense_claim_state("pending_payment", "payment")
paid_state = normalize_expense_claim_state("paid", "payment")
assert pending_state.approval_stage == PAYMENT_PENDING_STAGE
assert paid_state.approval_stage == PAYMENT_PAID_STAGE
assert claim_status_code("paid") == 50

View File

@@ -25,7 +25,7 @@ def build_session() -> Session:
return session_factory()
def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> None:
def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() -> None:
now = datetime.now(UTC)
with build_session() as db:
@@ -85,6 +85,24 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
created_at=now - timedelta(hours=1),
updated_at=now - timedelta(hours=1),
),
ExpenseClaim(
claim_no="AP-DASH-ADMIN-001",
employee_name="admin",
department_name="Finance",
expense_type="travel_application",
reason="admin pre-approval should not enter reimbursement metrics",
location="Shanghai",
amount=Decimal("999999.00"),
invoice_count=1,
occurred_at=now - timedelta(minutes=20),
submitted_at=now - timedelta(minutes=10),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(minutes=20),
updated_at=now - timedelta(minutes=10),
),
]
)
db.add(
@@ -144,12 +162,175 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
)
assert dashboard.has_real_data is True
assert dashboard.totals["pendingCount"] == 1
assert dashboard.totals["pendingAmount"] == 1200.0
assert dashboard.totals["riskCount"] == 1
assert dashboard.totals["reimbursementCount"] == 2
assert dashboard.totals["reimbursementAmount"] == 2000.0
assert dashboard.totals["pendingPaymentAmount"] == 0.0
assert dashboard.trend["applications"][-1] >= 1
assert "AP-DASH-ADMIN-001" not in str(dashboard.trend)
assert dashboard.spend_by_category[0]["value"] == 1200.0
assert dashboard.department_ranking[0]["name"] == "财务部"
assert dashboard.department_ranking[0]["amount"] == 1200.0
assert dashboard.employee_ranking[0]["name"] == "陈雨晴"
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
assert dashboard.budget_summary["ratio"] == 40.0
assert dashboard.budget_summary["used"] == "¥4,000"
metric_labels = {item["label"] for item in dashboard.budget_metrics}
assert {"预算池数量", "总预算", "已用预算", "可用预算", "预警预算池"}.issubset(
metric_labels
)
def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> None:
now = datetime.now(UTC)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="CLM-DASH-LABEL-001",
employee_name="林嘉宁",
department_name="市场部",
expense_type="travel_application",
reason="客户拜访差旅",
location="上海",
amount=Decimal("700.00"),
invoice_count=1,
occurred_at=now - timedelta(hours=2),
submitted_at=now - timedelta(hours=1),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[{"type": "budget_pressure"}],
hermes_risk_flag=False,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=1),
),
ExpenseClaim(
claim_no="CLM-DASH-LABEL-002",
employee_name="周思远",
department_name="财务部",
expense_type="meal",
reason="客户沟通",
location="杭州",
amount=Decimal("300.00"),
invoice_count=1,
occurred_at=now - timedelta(days=1),
submitted_at=now - timedelta(days=1),
status="paid",
approval_stage="payment",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(days=1),
updated_at=now - timedelta(days=1),
),
ExpenseClaim(
claim_no="CLM-DASH-LABEL-003",
employee_name="reimbursement-user",
department_name="甯傚満閮?,
expense_type="travel",
reason="real travel reimbursement",
location="Shanghai",
amount=Decimal("700.00"),
invoice_count=1,
occurred_at=now - timedelta(hours=2),
submitted_at=now - timedelta(hours=1),
status="submitted",
approval_stage="finance_review",
risk_flags_json=[],
hermes_risk_flag=False,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=1),
),
]
)
db.add_all(
[
RiskObservation(
observation_key="risk-dashboard-label-001",
subject_type="expense_claim",
subject_key="CLM-DASH-LABEL-001",
subject_label="CLM-DASH-LABEL-001",
claim_no="CLM-DASH-LABEL-001",
risk_type="policy",
risk_signal="missing_material",
title="材料不完整",
risk_level="medium",
status="pending_review",
created_at=now - timedelta(minutes=30),
updated_at=now - timedelta(minutes=30),
),
RiskObservation(
observation_key="risk-dashboard-label-002",
subject_type="expense_claim",
subject_key="CLM-DASH-LABEL-001",
subject_label="CLM-DASH-LABEL-001",
claim_no="CLM-DASH-LABEL-001",
risk_type="budget",
risk_signal="budget_pressure",
title="预算压力偏高",
risk_level="high",
status="pending_review",
created_at=now - timedelta(minutes=20),
updated_at=now - timedelta(minutes=20),
),
]
)
allocation = BudgetAllocation(
budget_no="BUD-DASH-LABEL-001",
fiscal_year=now.year,
period_type="year",
period_key=f"{now.year}",
department_name="市场部",
subject_code="travel",
subject_name="差旅费",
original_amount=Decimal("1000.00"),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="warn",
)
db.add(allocation)
db.flush()
db.add(
BudgetTransaction(
transaction_no="BTX-DASH-LABEL-001",
allocation_id=allocation.id,
source_type="expense_claim",
source_id="CLM-DASH-LABEL-003",
source_no="CLM-DASH-LABEL-003",
transaction_type="consume",
amount=Decimal("1250.00"),
before_available_amount=Decimal("1000.00"),
after_available_amount=Decimal("-250.00"),
operator="finance",
reason="测试超支",
created_at=now - timedelta(minutes=10),
)
)
db.commit()
dashboard = FinanceDashboardService(db).build_dashboard(
range_key="近10日",
trend_range="近7天",
department_range="本月",
)
spend_names = {item["name"] for item in dashboard.spend_by_category}
focus_names = {item["name"] for item in dashboard.bottlenecks}
assert "差旅" in spend_names
assert "travel_application" not in str(dashboard.spend_by_category)
assert "风险" not in str(dashboard.exception_mix)
assert "异常" not in str(dashboard.exception_mix)
assert "missing material" not in str(dashboard.exception_mix).lower()
assert "budget pressure" not in str(dashboard.exception_mix).lower()
assert dashboard.trend["claimCount"][-1] == 1
assert dashboard.trend["claimAmount"][-1] == 700.0
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
assert dashboard.department_ranking[0]["name"] == "市场部"
assert dashboard.department_ranking[0]["amount"] == 700.0
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
assert "风险金额" not in focus_names
assert "材料待补" not in focus_names
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
assert len(dashboard.budget_metrics) == 6

View File

@@ -710,7 +710,8 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
assert response.status == "blocked"
assert response.trace_summary.scenario == "expense"
assert "费用申请" in result["answer"]
assert "| 行程时间 | 2026-05-25" in result["answer"]
assert "| 出发时间 | 2026-05-25 |" in result["answer"]
assert "| 返回时间 | 2026-05-27 |" in result["answer"]
assert "请先在下面选择报销场景" not in result["answer"]
assert result.get("review_payload") is None
@@ -773,8 +774,10 @@ def test_orchestrator_application_session_guides_transport_estimate_and_submit(
assert "这是费用申请核对结果" in second.result["answer"]
assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"]
assert "| 系统预估费用 |" in second.result["answer"]
assert "按 2026-05-25 参考票价" in second.result["answer"]
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in second.result["answer"]
assert "2,330元" in second.result["answer"]
assert "参考票价" not in second.result["answer"]
assert "查询耗时" not in second.result["answer"]
assert "请核对上述信息无误" in second.result["answer"]
assert "[确认](#application-submit)" in second.result["answer"]
assert second.status == "blocked"

View File

@@ -209,7 +209,8 @@ def test_user_agent_application_context_uses_application_language() -> None:
assert "费用申请" in response.answer
assert "| 字段 | 内容 |" in response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-27 |" in response.answer
assert "支持上海国网服务器部署" in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert "请先在下面选择报销场景" not in response.answer
@@ -224,7 +225,8 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
with session_factory() as db:
response = build_application_user_agent_response(db, message)
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-27 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
assert "当前还需要先补充:申请事由" not in response.answer
@@ -250,7 +252,8 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 出发时间 | 2026-05-25 |" in yili_response.answer
assert "| 返回时间 | 2026-05-27 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer
@@ -289,7 +292,8 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
)
)
assert "| 行程时间 | 2026-05-25 |" in response.answer
assert "| 出发时间 | 2026-05-25 |" in response.answer
assert "| 返回时间 | 2026-05-25 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
assert "当前还需要补充:出行方式" in response.answer
@@ -317,10 +321,10 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
assert "| 出行方式 | 飞机 |" in response.answer
assert "| 系统预估费用 |" in response.answer
assert "交通" in response.answer
assert "参考票价" in response.answer
assert "按 2026-05-25 参考票价" in response.answer
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in response.answer
assert "2,330元" in response.answer
assert "查询耗时" in response.answer
assert "参考票价" not in response.answer
assert "查询耗时" not in response.answer
assert response.requires_confirmation is True
assert response.suggested_actions == []
@@ -362,14 +366,64 @@ def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> N
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 住宿上限/天 | 450元/天 |" in response.answer
assert "| 补贴标准/天 | 100元/天 |" in response.answer
assert "| 规则测算参考 | 交通 2,460元 + 住宿 1,800元 + 补贴 400元 = 4,660元4天 |" in response.answer
assert "| 发生时间 |" not in response.answer
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
def test_user_agent_application_keeps_labeled_reason_in_structured_travel_form() -> None:
session_factory = build_session_factory()
message = (
"发生时间2026-02-20 至 2026-02-23\n"
"地点:上海\n"
"事由:支撑国网仿生产环境建设\n"
"天数4天"
)
context_json = {
"session_type": "application",
"entry_source": "application",
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-structured-application-reason@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-structured-application-reason@example.com",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 申请类型 | 差旅费用申请 |" in response.answer
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境建设 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "申请事由" not in response.answer
assert "当前还需要补充:出行方式" in response.answer
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
session_factory = build_session_factory()
message = "去上海出差,支撑国网仿生产服务器部署,火车"
@@ -418,7 +472,8 @@ def test_user_agent_application_derives_days_from_selected_date_range() -> None:
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 出发时间 | 2026-02-20 |" in response.answer
assert "| 返回时间 | 2026-02-23 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 天数 | 待补充 |" not in response.answer
assert "4天" in response.answer
@@ -452,7 +507,8 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
)
assert "这是费用申请核对结果" in response.answer
assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert "| 出发时间 | 2026-05-29 |" in response.answer
assert "| 返回时间 | 2026-05-31 |" in response.answer
assert response.requires_confirmation is True
@@ -547,7 +603,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
@@ -585,7 +642,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
session_factory = build_session_factory()
initial_message = (
"行程时间2026-05-25 至 2026-05-27\n"
"出发时间2026-05-25\n"
"返回时间2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
@@ -597,7 +655,8 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
"| 出发时间 | 2026-05-25 |\n"
"| 返回时间 | 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
@@ -1385,6 +1444,7 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
"application_transport_mode": "火车",
},
"user_input_text": message,
}
@@ -1412,6 +1472,15 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
assert slot_map["location"].value == "北京"
assert slot_map["amount"].value == "3000.00元"
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
assert UserAgentService._resolve_review_form_values(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
)
)["transport_mode"] == "火车"
assert "事由说明" not in response.review_payload.missing_slots