feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user