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

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