feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -16,10 +16,12 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
@@ -108,6 +110,16 @@ def _seed_budget_allocation(
|
||||
return allocation
|
||||
|
||||
|
||||
def _seed_budget_monitor_role(db: Session) -> Role:
|
||||
role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
|
||||
if role is not None:
|
||||
return role
|
||||
role = Role(role_code="budget_monitor", name="预算监控员")
|
||||
db.add(role)
|
||||
db.flush()
|
||||
return role
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="office", location="待补充")
|
||||
@@ -3320,32 +3332,55 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
budget_user = CurrentUserContext(
|
||||
username="budget-p8-application-approve@example.com",
|
||||
name="赵预算",
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-BUDGET-APPROVE",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E8112",
|
||||
name="李经理",
|
||||
email="manager-application-approve@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no="E8112-BUDGET",
|
||||
name="赵预算",
|
||||
email="budget-p8-application-approve@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8113",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-APPROVE",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id=department.id,
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
@@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
leader_approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
manager_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
assert leader_approved is not None
|
||||
assert leader_approved.status == "submitted"
|
||||
assert leader_approved.approval_stage == "预算管理者审批"
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "业务必要,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("next_status") == "submitted"
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
and flag.get("next_approver_name") == "赵预算"
|
||||
and flag.get("next_approver_grade") == "P8"
|
||||
for flag in leader_approved.risk_flags_json
|
||||
)
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
budget_user,
|
||||
opinion="预算额度可承接,同意。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
@@ -3400,14 +3458,15 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
)
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "业务必要,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
and flag.get("source") == "budget_approval"
|
||||
and flag.get("event_type") == "expense_application_budget_approval"
|
||||
and flag.get("opinion") == "预算额度可承接,同意。"
|
||||
and flag.get("previous_approval_stage") == "预算管理者审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("generated_draft_claim_id") == generated_draft.id
|
||||
@@ -3473,7 +3532,7 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
|
||||
claim.id,
|
||||
manager_user,
|
||||
reason="预算说明不够清楚,请补充项目必要性。",
|
||||
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
|
||||
reason_codes=["application_budget_basis_missing"],
|
||||
)
|
||||
|
||||
assert returned is not None
|
||||
@@ -3493,11 +3552,8 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
|
||||
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
||||
assert return_event["return_stage"] == "直属领导审批"
|
||||
assert return_event["return_stage_key"] == "direct_manager"
|
||||
assert return_event["reason_codes"] == [
|
||||
"application_business_need_unclear",
|
||||
"application_budget_basis_missing",
|
||||
]
|
||||
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
|
||||
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
|
||||
assert return_event["risk_points"] == ["预算测算依据不足"]
|
||||
assert return_event["next_status"] == "returned"
|
||||
assert return_event["next_approval_stage"] == "待提交"
|
||||
|
||||
@@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
budget_user = CurrentUserContext(
|
||||
username="budget-p8-transfer@example.com",
|
||||
name="赵预算",
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
id="dept-budget-transfer",
|
||||
unit_code="DELIVERY-BUDGET-TRANSFER",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="M-BUDGET-APP",
|
||||
name="李经理",
|
||||
email="manager-application-budget@example.com",
|
||||
organization_unit=department,
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no="P8-BUDGET-APP",
|
||||
name="赵预算",
|
||||
email="budget-p8-transfer@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-APP",
|
||||
name="张三",
|
||||
email="application-budget-owner-approve@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
@@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
service.submit_claim(claim.id, owner)
|
||||
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
|
||||
reservation = db.query(BudgetReservation).one()
|
||||
|
||||
assert leader_approved is not None
|
||||
assert leader_approved.approval_stage == "预算管理者审批"
|
||||
assert reservation.source_type == "application"
|
||||
assert reservation.source_id == claim.id
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
db.refresh(reservation)
|
||||
|
||||
assert approved is not None
|
||||
assert reservation.source_type == "claim"
|
||||
assert reservation.source_id == generated_draft.id
|
||||
@@ -3576,7 +3664,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-required-opinion@example.com",
|
||||
name="李经理",
|
||||
@@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="领导审核意见不能为空"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
assert approved is not None
|
||||
assert approved.status == "submitted"
|
||||
assert approved.approval_stage == "预算管理者审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("event_type") == "expense_application_approval"
|
||||
and flag.get("opinion") == "同意"
|
||||
and flag.get("next_approval_stage") == "预算管理者审批"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
|
||||
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
||||
owner = CurrentUserContext(
|
||||
username="application-budget-analysis-owner@example.com",
|
||||
name="张三",
|
||||
role_codes=["employee"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-ANALYSIS",
|
||||
name="张三",
|
||||
email="application-budget-analysis-owner@example.com",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
department_id="dept-budget-analysis",
|
||||
department_name="交付部",
|
||||
amount=Decimal("50000.00"),
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-ANALYSIS",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id="dept-budget-analysis",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="客户现场交付",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
ExpenseClaimService(db).submit_claim(claim.id, owner)
|
||||
analysis = BudgetService(db).analyze_claim_budget(claim)
|
||||
|
||||
assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
|
||||
assert analysis["metrics"]["after_usage_rate"] == "24.00"
|
||||
assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
|
||||
assert analysis["score"] >= 70
|
||||
assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"])
|
||||
|
||||
|
||||
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-budget-approve@example.com",
|
||||
@@ -4207,3 +4355,121 @@ def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
|
||||
def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="budget-p8-list@example.com",
|
||||
name="赵预算",
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
)
|
||||
p8_without_budget_role = CurrentUserContext(
|
||||
username="budget-p8-list@example.com",
|
||||
name="budget manager",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
delivery_department = OrganizationUnit(
|
||||
unit_code="DELIVERY-BUDGET-LIST",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
market_department = OrganizationUnit(
|
||||
unit_code="MARKET-BUDGET-LIST",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
)
|
||||
budget_manager = Employee(
|
||||
employee_no="E-P8-BUDGET-LIST",
|
||||
name="赵预算",
|
||||
email="budget-p8-list@example.com",
|
||||
grade="P8",
|
||||
organization_unit=delivery_department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-LIST-OWNER",
|
||||
name="张三",
|
||||
email="budget-list-owner@example.com",
|
||||
organization_unit=delivery_department,
|
||||
)
|
||||
market_employee = Employee(
|
||||
employee_no="E-BUDGET-LIST-MARKET",
|
||||
name="王五",
|
||||
email="budget-list-market@example.com",
|
||||
organization_unit=market_department,
|
||||
)
|
||||
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="APP-BUDGET-LIST-201",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id=delivery_department.id,
|
||||
department_name="交付部",
|
||||
project_code="PRJ-BUDGET",
|
||||
expense_type="travel_application",
|
||||
reason="预算待审申请",
|
||||
location="上海",
|
||||
amount=Decimal("12000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="预算管理者审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="APP-BUDGET-LIST-OTHER-DEPT",
|
||||
employee_id=market_employee.id,
|
||||
employee_name="王五",
|
||||
department_id=market_department.id,
|
||||
department_name="市场部",
|
||||
project_code="PRJ-BUDGET",
|
||||
expense_type="travel_application",
|
||||
reason="其他部门预算待审申请",
|
||||
location="上海",
|
||||
amount=Decimal("13000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="预算管理者审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-BUDGET-LIST-202",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id=delivery_department.id,
|
||||
department_name="交付部",
|
||||
project_code="PRJ-BUDGET",
|
||||
expense_type="transport",
|
||||
reason="财务待审报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.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="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
|
||||
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
|
||||
assert [claim.claim_no for claim in claims_without_budget_role] == []
|
||||
|
||||
Reference in New Issue
Block a user